Skip to content

Commit d570834

Browse files
committed
tweak(notifications): blur and pause notifications when game is paused.
1 parent da0691c commit d570834

4 files changed

Lines changed: 190 additions & 9 deletions

File tree

assets/enhanced/ui/notifications.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
flex-direction: column;
1717
gap: var(--toast-gap);
1818
pointer-events: none;
19+
transition: filter 0.25s ease, opacity 0.25s ease;
20+
}
21+
22+
/* The game blurs the world behind the pause menu, and the stack sits in that world, so it blurs with it. */
23+
#toasts.paused {
24+
filter: blur(2px);
25+
opacity: 0.75;
1926
}
2027

2128
.toast {

assets/enhanced/ui/notifications.js

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
/* Long enough for `.leaving` to finish; the row is only removed once it has faded out. */
99
const EXIT_MS = 200;
1010

11+
/* Handed back to a row the pause menu froze, so there is time to read it once the game returns. */
12+
const PAUSE_GRACE_MS = 2000;
13+
1114
const ICONS = {
1215
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11.2v5"/><path d="M12 7.6h.01"/>',
1316
success: '<circle cx="12" cy="12" r="9"/><path d="M7.9 12.3l2.8 2.8 5.4-5.6"/>',
@@ -36,6 +39,7 @@
3639
const queued = [];
3740

3841
let shown = 0;
42+
let paused = false;
3943

4044
/*
4145
Written per message rather than per frame: the client only recalculates where the map ends
@@ -142,13 +146,68 @@
142146
replay(entry.badge);
143147
}
144148

145-
/* Puts the row back on a full timer, so a repeat keeps the message on screen instead of ending it sooner. */
146-
function restart(entry) {
149+
/*
150+
Starts, or restarts, a row's countdown with `remaining` of its duration left to run. The bar's
151+
keyframe always spans the whole duration and is wound forward with a negative delay, so a row
152+
coming back from a pause picks up at the width its remaining time deserves rather than
153+
snapping back to full.
154+
*/
155+
function run(entry, remaining) {
147156
clearTimeout(entry.timer);
148-
entry.timer = setTimeout(() => dismiss(entry), entry.duration);
157+
158+
entry.remaining = remaining;
149159

150160
replay(entry.progress);
151161
entry.progress.style.animationDuration = `${entry.duration}ms`;
162+
entry.progress.style.animationDelay = `-${entry.duration - remaining}ms`;
163+
164+
if (paused) {
165+
entry.progress.style.animationPlayState = "paused";
166+
entry.timer = 0;
167+
168+
return;
169+
}
170+
171+
entry.endsAt = performance.now() + remaining;
172+
entry.timer = setTimeout(() => dismiss(entry), remaining);
173+
}
174+
175+
/* Keeps what is left of a row's time instead of spending it behind the pause menu. */
176+
function freeze(entry) {
177+
clearTimeout(entry.timer);
178+
179+
entry.timer = 0;
180+
entry.remaining = Math.max(0, entry.endsAt - performance.now());
181+
entry.progress.style.animationPlayState = "paused";
182+
}
183+
184+
/* Never past the time the row was given, so pausing over and over cannot keep it on screen. */
185+
function thaw(entry) {
186+
run(entry, Math.min(entry.remaining + PAUSE_GRACE_MS, entry.duration));
187+
}
188+
189+
function setPaused(next) {
190+
if (next === paused) {
191+
return;
192+
}
193+
194+
paused = next;
195+
196+
// Blurred along with the world the pause menu draws over.
197+
listEl.classList.toggle("paused", paused);
198+
199+
for (const entry of tracked.values()) {
200+
// A row still waiting its turn has no time to hold; it is started when it reaches the screen.
201+
if (!entry.toast) {
202+
continue;
203+
}
204+
205+
if (paused) {
206+
freeze(entry);
207+
} else {
208+
thaw(entry);
209+
}
210+
}
152211
}
153212

154213
function dismiss(entry) {
@@ -197,7 +256,6 @@
197256

198257
const progress = document.createElement("span");
199258
progress.className = "progress";
200-
progress.style.animationDuration = `${entry.duration}ms`;
201259

202260
toast.append(bar, body);
203261

@@ -215,7 +273,8 @@
215273
entry.toast = toast;
216274
entry.badge = badge;
217275
entry.progress = progress;
218-
entry.timer = setTimeout(() => dismiss(entry), entry.duration);
276+
277+
run(entry, entry.duration);
219278

220279
// Carried over from the wait, where a row can pick up repeats before it ever reaches the screen.
221280
if (entry.repeats > 1) {
@@ -246,7 +305,9 @@
246305
// Nothing to redraw while it is still waiting; it is built with the count it has by then.
247306
if (repeat.toast) {
248307
count(repeat);
249-
restart(repeat);
308+
309+
// Back on a full timer, so a repeat keeps the message on screen instead of ending it sooner.
310+
run(repeat, repeat.duration);
250311
}
251312

252313
return;
@@ -263,6 +324,8 @@
263324
badge: null,
264325
progress: null,
265326
timer: 0,
327+
remaining: duration,
328+
endsAt: 0,
266329
};
267330

268331
tracked.set(key, entry);
@@ -287,7 +350,20 @@
287350
}
288351
}
289352

290-
if (data && typeof data === "object" && data.type === "notify") {
353+
if (!data || typeof data !== "object") {
354+
return;
355+
}
356+
357+
if (data.type === "notify_pause") {
358+
setPaused(data.paused === true);
359+
360+
return;
361+
}
362+
363+
if (data.type === "notify") {
364+
// Carried on the message itself, so one arriving during a pause is frozen from the start.
365+
setPaused(data.paused === true);
366+
291367
notify(data);
292368
}
293369
});

src/Client/vMenu.Enhanced.MenuFramework/Notifications.cs

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using CitizenFX.FiveM.Client;
22

3+
using vMenu.Enhanced.Data.Ticks;
34
using vMenu.Enhanced.MenuFramework.Localization;
45
using vMenu.Enhanced.Serialization;
6+
using vMenu.Enhanced.Ticks;
57

68
namespace vMenu.Enhanced.MenuFramework;
79

@@ -26,6 +28,27 @@ public static class Notifications
2628
/// <summary>Thirty seconds of waiting for a spawn, after which the message is shown regardless.</summary>
2729
private const int MaxVisibilityChecks = 60;
2830

31+
/// <summary>What the page hands a frozen message back when the pause menu closes, mirrored here.</summary>
32+
private const int PauseGraceMs = 2000;
33+
34+
/// <summary>Covers the fade the page plays after a message's time is up.</summary>
35+
private const int ExitGraceMs = 500;
36+
37+
private const long PauseCheckIntervalMs = 100;
38+
39+
private const string PausedMessage = """{"type":"notify_pause","paused":true}""";
40+
41+
private const string ResumedMessage = """{"type":"notify_pause","paused":false}""";
42+
43+
private static TickHandle? _pauseTick;
44+
45+
private static bool _paused;
46+
47+
/// <summary>Game time by which every message sent so far has had its say.</summary>
48+
private static long _liveUntil;
49+
50+
private static long _polledAt;
51+
2952
public static void Info(MenuText text, int durationMs = DefaultDurationMs) =>
3053
Show(NotificationStyle.Info, text, durationMs);
3154

@@ -73,7 +96,75 @@ public static void Show(
7396
return;
7497
}
7598

76-
Native.SendNuiMessage(BuildMessage(Name(style), message, durationMs, source));
99+
// Read once and sent with the message, so a notification raised during a pause is drawn the
100+
// way the ones already on screen are instead of waiting for the next poll to catch up.
101+
var paused = Native.IsPauseMenuActive();
102+
103+
Native.SendNuiMessage(BuildMessage(Name(style), message, durationMs, source, paused));
104+
105+
Watch(durationMs, paused);
106+
}
107+
108+
/// <summary>Keeps the pause menu watch alive for as long as this message can still be on screen.</summary>
109+
// An estimate, never the truth: the page owns the timers, and this side only needs to know when
110+
// watching is pointless. It rounds up on a repeat, which restarts the row and this window with
111+
// it, and falls short only when messages arrive faster than the page shows them, where the ones
112+
// still queued go without their blur and their extra time.
113+
private static void Watch(int durationMs, bool paused)
114+
{
115+
var until = Native.GetGameTimer() + durationMs + ExitGraceMs;
116+
117+
if (until > _liveUntil)
118+
{
119+
_liveUntil = until;
120+
}
121+
122+
// Registered on the first message rather than from an Initialize, so a client that never
123+
// notifies never lists a loop, and nothing has to be ordered in front of this.
124+
_pauseTick ??= TickRegistry.Register(
125+
"Notifications.Pause",
126+
PollPause,
127+
TickRate.Every(PauseCheckIntervalMs),
128+
() => _liveUntil > Native.GetGameTimer(),
129+
() => _polledAt = Native.GetGameTimer());
130+
131+
// The page is told the state on every message, so the poll has nothing left to announce.
132+
_paused = paused;
133+
134+
_pauseTick.Reevaluate();
135+
}
136+
137+
private static void PollPause()
138+
{
139+
var now = Native.GetGameTimer();
140+
var paused = Native.IsPauseMenuActive();
141+
142+
// The page holds its messages for as long as the pause menu is up, so this window holds with them.
143+
if (paused)
144+
{
145+
_liveUntil += now - _polledAt;
146+
}
147+
148+
_polledAt = now;
149+
150+
if (paused != _paused)
151+
{
152+
_paused = paused;
153+
154+
// Mirrors the grace the page hands back, so watching does not end while a message is still living on it.
155+
if (!paused)
156+
{
157+
_liveUntil += PauseGraceMs;
158+
}
159+
160+
Native.SendNuiMessage(paused ? PausedMessage : ResumedMessage);
161+
}
162+
163+
// Conditions are only re-run on demand, so the loop has to be the one to notice it is done.
164+
if (now >= _liveUntil)
165+
{
166+
_pauseTick?.Reevaluate();
167+
}
77168
}
78169

79170
/// <summary>The box the stack grows out of, as fractions of the screen, lined up with the minimap.</summary>
@@ -99,7 +190,7 @@ private static float MinimapWidth()
99190
return aspect > 0f ? 1f / (4f * aspect) : 1f / 4f;
100191
}
101192

102-
private static string BuildMessage(string style, string text, int durationMs, string? source)
193+
private static string BuildMessage(string style, string text, int durationMs, string? source, bool paused)
103194
{
104195
var (left, bottom, width) = Anchor();
105196

@@ -109,6 +200,7 @@ private static string BuildMessage(string style, string text, int durationMs, st
109200
Text = text,
110201
Duration = durationMs,
111202
Footer = string.IsNullOrWhiteSpace(source) ? null : source,
203+
Paused = paused,
112204
Anchor = new AnchorBox
113205
{
114206
Left = Fraction(left),
@@ -133,6 +225,9 @@ private sealed class NotifyMessage
133225

134226
public string? Footer { get; init; }
135227

228+
/// <summary>Whether the pause menu is up, which is also the state the page as a whole goes into.</summary>
229+
public required bool Paused { get; init; }
230+
136231
public required AnchorBox Anchor { get; init; }
137232
}
138233

src/Client/vMenu.Enhanced.MenuFramework/vMenu.Enhanced.MenuFramework.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
<ProjectReference Include="..\vMenu.Enhanced.Configuration\vMenu.Enhanced.Configuration.csproj" />
1212
<ProjectReference Include="..\vMenu.Enhanced.Permissions\vMenu.Enhanced.Permissions.csproj" />
1313
<ProjectReference Include="..\vMenu.Enhanced.Serialization\vMenu.Enhanced.Serialization.csproj" />
14+
15+
<!-- Notifications watch the pause menu from a registered tick while a message is on screen. -->
16+
<ProjectReference Include="..\vMenu.Enhanced.Ticks\vMenu.Enhanced.Ticks.csproj" />
1417
</ItemGroup>
1518

1619
</Project>

0 commit comments

Comments
 (0)