Upload folder using huggingface_hub
Browse files- static/css/radio.css +72 -1
- static/js/radio.js +103 -51
static/css/radio.css
CHANGED
|
@@ -1583,6 +1583,76 @@ body {
|
|
| 1583 |
}
|
| 1584 |
.notes-list li.just-added { animation: note-flash 1.1s ease-out; }
|
| 1585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1586 |
/* ===================================================================
|
| 1587 |
Responsive
|
| 1588 |
=================================================================== */
|
|
@@ -1658,7 +1728,8 @@ body {
|
|
| 1658 |
}
|
| 1659 |
.guide-btn::after,
|
| 1660 |
.intercept.urgent,
|
| 1661 |
-
.notes-list li.just-added
|
|
|
|
| 1662 |
animation: none;
|
| 1663 |
}
|
| 1664 |
.noise-canvas,
|
|
|
|
| 1583 |
}
|
| 1584 |
.notes-list li.just-added { animation: note-flash 1.1s ease-out; }
|
| 1585 |
|
| 1586 |
+
/* ===================================================================
|
| 1587 |
+
Endgame mission HUD (fragment hunt in the secret band)
|
| 1588 |
+
=================================================================== */
|
| 1589 |
+
.mission-hud {
|
| 1590 |
+
display: flex;
|
| 1591 |
+
flex-direction: column;
|
| 1592 |
+
gap: 7px;
|
| 1593 |
+
width: 100%;
|
| 1594 |
+
max-width: 620px;
|
| 1595 |
+
margin: 0 auto;
|
| 1596 |
+
padding: 9px 16px;
|
| 1597 |
+
border: 1px solid rgba(255, 246, 216, 0.4);
|
| 1598 |
+
border-radius: 9px;
|
| 1599 |
+
background: rgba(8, 14, 6, 0.72);
|
| 1600 |
+
box-shadow: 0 0 26px rgba(255, 246, 216, 0.12), inset 0 0 18px rgba(0, 0, 0, 0.5);
|
| 1601 |
+
animation: mission-glow 2.6s ease-in-out infinite;
|
| 1602 |
+
}
|
| 1603 |
+
|
| 1604 |
+
.mission-hud.hidden { display: none; }
|
| 1605 |
+
|
| 1606 |
+
@keyframes mission-glow {
|
| 1607 |
+
0%, 100% { box-shadow: 0 0 18px rgba(255, 246, 216, 0.1), inset 0 0 18px rgba(0, 0, 0, 0.5); }
|
| 1608 |
+
50% { box-shadow: 0 0 34px rgba(255, 246, 216, 0.22), inset 0 0 18px rgba(0, 0, 0, 0.5); }
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
.mission-hud-text {
|
| 1612 |
+
font-size: 0.82rem;
|
| 1613 |
+
letter-spacing: 0.08em;
|
| 1614 |
+
text-align: center;
|
| 1615 |
+
color: #fff6d8;
|
| 1616 |
+
text-shadow: 0 0 10px rgba(255, 246, 216, 0.4);
|
| 1617 |
+
}
|
| 1618 |
+
|
| 1619 |
+
.mission-hud-track {
|
| 1620 |
+
height: 9px;
|
| 1621 |
+
border-radius: 5px;
|
| 1622 |
+
background: rgba(0, 0, 0, 0.55);
|
| 1623 |
+
border: 1px solid rgba(255, 246, 216, 0.2);
|
| 1624 |
+
overflow: hidden;
|
| 1625 |
+
}
|
| 1626 |
+
|
| 1627 |
+
.mission-hud-fill {
|
| 1628 |
+
height: 100%;
|
| 1629 |
+
width: 0%;
|
| 1630 |
+
background: linear-gradient(90deg, #8a7a3a, #fff6d8);
|
| 1631 |
+
box-shadow: 0 0 10px rgba(255, 246, 216, 0.6);
|
| 1632 |
+
transition: width 0.12s linear;
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
.mission-hud-pips {
|
| 1636 |
+
display: flex;
|
| 1637 |
+
justify-content: center;
|
| 1638 |
+
gap: 9px;
|
| 1639 |
+
}
|
| 1640 |
+
|
| 1641 |
+
.mission-hud-pips span {
|
| 1642 |
+
width: 11px;
|
| 1643 |
+
height: 11px;
|
| 1644 |
+
border-radius: 50%;
|
| 1645 |
+
border: 1px solid rgba(255, 246, 216, 0.45);
|
| 1646 |
+
background: rgba(255, 246, 216, 0.06);
|
| 1647 |
+
transition: background 0.25s, box-shadow 0.25s;
|
| 1648 |
+
}
|
| 1649 |
+
|
| 1650 |
+
.mission-hud-pips span.on {
|
| 1651 |
+
background: #fff6d8;
|
| 1652 |
+
border-color: #fff6d8;
|
| 1653 |
+
box-shadow: 0 0 10px rgba(255, 246, 216, 0.8);
|
| 1654 |
+
}
|
| 1655 |
+
|
| 1656 |
/* ===================================================================
|
| 1657 |
Responsive
|
| 1658 |
=================================================================== */
|
|
|
|
| 1728 |
}
|
| 1729 |
.guide-btn::after,
|
| 1730 |
.intercept.urgent,
|
| 1731 |
+
.notes-list li.just-added,
|
| 1732 |
+
.mission-hud {
|
| 1733 |
animation: none;
|
| 1734 |
}
|
| 1735 |
.noise-canvas,
|
static/js/radio.js
CHANGED
|
@@ -66,6 +66,10 @@ const notesScratchLabelEl = document.getElementById("notes-scratch-label");
|
|
| 66 |
const notesFoundEl = document.getElementById("notes-found");
|
| 67 |
const notesCipherEl = document.getElementById("notes-cipher");
|
| 68 |
const notesScratch = document.getElementById("notes-scratch");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
// =====================================================================
|
| 71 |
// i18n: the listener picks a language (affects UI + model broadcasts)
|
|
@@ -110,9 +114,12 @@ const I18N = {
|
|
| 110 |
numberHint:
|
| 111 |
"Escucha el Morse · escribe la secuencia de números en la consola para entrar",
|
| 112 |
cipherBroken: "— CIFRADO ROTO — la banda 108-112 MHz existe ahora —",
|
| 113 |
-
unlockHint: (code) => `código ${code} aceptado · el dial creció ·
|
| 114 |
downloading: (name, pct) => `↓ ${name} — ${pct}% · sostén el dial`,
|
| 115 |
-
received: (name, a, b) => `✓ ${name}
|
|
|
|
|
|
|
|
|
|
| 116 |
finaleTitle: "— EL ÚLTIMO MENSAJE —",
|
| 117 |
finaleEnd: "— fin de todas las transmisiones · gracias por escuchar —",
|
| 118 |
intro:
|
|
@@ -162,9 +169,12 @@ const I18N = {
|
|
| 162 |
numberHint:
|
| 163 |
"Listen to the Morse · type the number sequence into the console to break in",
|
| 164 |
cipherBroken: "— CIPHER BROKEN — the 108-112 MHz band exists now —",
|
| 165 |
-
unlockHint: (code) => `code ${code} accepted · the dial grew ·
|
| 166 |
downloading: (name, pct) => `↓ ${name} — ${pct}% · hold the dial steady`,
|
| 167 |
-
received: (name, a, b) => `✓ ${name}
|
|
|
|
|
|
|
|
|
|
| 168 |
finaleTitle: "— THE LAST MESSAGE —",
|
| 169 |
finaleEnd: "— end of all transmissions · thank you for listening —",
|
| 170 |
intro:
|
|
@@ -214,9 +224,12 @@ const I18N = {
|
|
| 214 |
numberHint:
|
| 215 |
"Écoute le morse · tape la séquence de nombres dans la console pour entrer",
|
| 216 |
cipherBroken: "— CHIFFREMENT BRISÉ — la bande 108-112 MHz existe maintenant —",
|
| 217 |
-
unlockHint: (code) => `code ${code} accepté · le cadran
|
| 218 |
downloading: (name, pct) => `↓ ${name} — ${pct}% · tenez le cadran`,
|
| 219 |
-
received: (name, a, b) => `✓ ${name}
|
|
|
|
|
|
|
|
|
|
| 220 |
finaleTitle: "— LE DERNIER MESSAGE —",
|
| 221 |
finaleEnd: "— fin de toutes les transmissions · merci d'avoir écouté —",
|
| 222 |
intro:
|
|
@@ -614,8 +627,15 @@ async function restoreProgress() {
|
|
| 614 |
unlockCode = saved.code;
|
| 615 |
collectedFragments = new Set(saved.collected || []);
|
| 616 |
const ok = await loadHiddenBand(unlockCode);
|
| 617 |
-
if (ok)
|
| 618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
}
|
| 620 |
} catch (_) {
|
| 621 |
/* corrupted state: ignored */
|
|
@@ -1345,26 +1365,30 @@ function animate(now) {
|
|
| 1345 |
inertiaActive = false;
|
| 1346 |
freqVel = 0;
|
| 1347 |
frequency = Math.round(frequency * 10) / 10;
|
| 1348 |
-
|
| 1349 |
-
|
| 1350 |
} else {
|
| 1351 |
updateDialUI();
|
| 1352 |
}
|
| 1353 |
}
|
| 1354 |
|
| 1355 |
-
// hidden band: the world
|
| 1356 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
!isDragging &&
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
frequency = Math.min(freqMax, Math.max(FREQ_MIN, frequency + driftVel * dt));
|
| 1366 |
-
updateDialUI();
|
| 1367 |
updateDownload(dt);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1368 |
}
|
| 1369 |
|
| 1370 |
// under-damped needle spring (overshoot while settling)
|
|
@@ -1468,7 +1492,7 @@ function onPointerUp(evt) {
|
|
| 1468 |
} else {
|
| 1469 |
freqVel = 0;
|
| 1470 |
setFrequency(frequency); // rounds to 0.1
|
| 1471 |
-
|
| 1472 |
}
|
| 1473 |
}
|
| 1474 |
|
|
@@ -1491,7 +1515,7 @@ function snapToStation(direction) {
|
|
| 1491 |
if (!candidates.length) return;
|
| 1492 |
const target = direction > 0 ? Math.min(...candidates) : Math.max(...candidates);
|
| 1493 |
setFrequency(target);
|
| 1494 |
-
|
| 1495 |
}
|
| 1496 |
|
| 1497 |
function onKeyDown(evt) {
|
|
@@ -1571,7 +1595,7 @@ function onKnobUp(evt) {
|
|
| 1571 |
} else {
|
| 1572 |
freqVel = 0;
|
| 1573 |
setFrequency(frequency);
|
| 1574 |
-
|
| 1575 |
}
|
| 1576 |
}
|
| 1577 |
|
|
@@ -1591,7 +1615,7 @@ function onDoubleClick() {
|
|
| 1591 |
if (freq === null) return;
|
| 1592 |
cancelIntro();
|
| 1593 |
setFrequency(freq);
|
| 1594 |
-
|
| 1595 |
}
|
| 1596 |
|
| 1597 |
// =====================================================================
|
|
@@ -1608,20 +1632,17 @@ async function ensureClient() {
|
|
| 1608 |
}
|
| 1609 |
|
| 1610 |
function cancelTune() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1611 |
tuneGeneration += 1;
|
| 1612 |
-
const job = currentJob;
|
| 1613 |
currentJob = null;
|
| 1614 |
-
if (!job) return;
|
| 1615 |
-
try {
|
| 1616 |
-
if (typeof job.done === "function" && job.done()) return;
|
| 1617 |
-
job.cancel();
|
| 1618 |
-
} catch (_) {
|
| 1619 |
-
// cancel on an already finished job → 422; ignore
|
| 1620 |
-
}
|
| 1621 |
}
|
| 1622 |
|
| 1623 |
async function tuneNow() {
|
| 1624 |
-
if (introActive) return;
|
| 1625 |
const strength = signalStrength(frequency);
|
| 1626 |
const locked = strength >= 0.95;
|
| 1627 |
const stationFreq = stationFrequency(frequency);
|
|
@@ -2019,18 +2040,21 @@ function updateDownload(dt) {
|
|
| 2019 |
const sf = stationFrequency(frequency);
|
| 2020 |
const meta = stationMeta(sf);
|
| 2021 |
const strength = signalStrength(frequency);
|
|
|
|
|
|
|
| 2022 |
|
| 2023 |
if (
|
| 2024 |
meta?.fragment &&
|
| 2025 |
sf !== null &&
|
| 2026 |
!collectedFragments.has(sf.toFixed(1)) &&
|
| 2027 |
-
strength >= 0.
|
| 2028 |
) {
|
| 2029 |
if (downloadTarget !== sf) {
|
| 2030 |
downloadTarget = sf;
|
| 2031 |
downloadProgress = 0;
|
| 2032 |
}
|
| 2033 |
-
downloadProgress = Math.min(1, downloadProgress + dt /
|
|
|
|
| 2034 |
if (hint) {
|
| 2035 |
hint.textContent = t().downloading(
|
| 2036 |
stationDisplayName(meta),
|
|
@@ -2041,23 +2065,45 @@ function updateDownload(dt) {
|
|
| 2041 |
collectedFragments.add(sf.toFixed(1));
|
| 2042 |
persistProgress();
|
| 2043 |
playJingle(meta.voice);
|
|
|
|
| 2044 |
downloadTarget = null;
|
| 2045 |
downloadProgress = 0;
|
| 2046 |
-
const
|
| 2047 |
-
if (hint)
|
| 2048 |
-
|
| 2049 |
-
|
| 2050 |
-
|
| 2051 |
-
total
|
| 2052 |
-
);
|
| 2053 |
-
}
|
| 2054 |
-
if (total > 0 && collectedFragments.size >= total) {
|
| 2055 |
-
setTimeout(startFinale, 1600);
|
| 2056 |
-
}
|
| 2057 |
}
|
| 2058 |
-
} else
|
| 2059 |
-
|
| 2060 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2061 |
}
|
| 2062 |
}
|
| 2063 |
|
|
@@ -2272,6 +2318,12 @@ function renderNotes(flashLast) {
|
|
| 2272 |
`<span class="nc-code">${escapeHtml(cipherCode)}</span>`;
|
| 2273 |
notesCipherEl.appendChild(d);
|
| 2274 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2275 |
}
|
| 2276 |
}
|
| 2277 |
}
|
|
|
|
| 66 |
const notesFoundEl = document.getElementById("notes-found");
|
| 67 |
const notesCipherEl = document.getElementById("notes-cipher");
|
| 68 |
const notesScratch = document.getElementById("notes-scratch");
|
| 69 |
+
const missionHud = document.getElementById("mission-hud");
|
| 70 |
+
const missionHudText = document.getElementById("mission-hud-text");
|
| 71 |
+
const missionHudFill = document.getElementById("mission-hud-fill");
|
| 72 |
+
const missionHudPips = document.getElementById("mission-hud-pips");
|
| 73 |
|
| 74 |
// =====================================================================
|
| 75 |
// i18n: the listener picks a language (affects UI + model broadcasts)
|
|
|
|
| 114 |
numberHint:
|
| 115 |
"Escucha el Morse · escribe la secuencia de números en la consola para entrar",
|
| 116 |
cipherBroken: "— CIFRADO ROTO — la banda 108-112 MHz existe ahora —",
|
| 117 |
+
unlockHint: (code) => `código ${code} aceptado · el dial creció a 108–112 MHz · reúne los 5 fragmentos`,
|
| 118 |
downloading: (name, pct) => `↓ ${name} — ${pct}% · sostén el dial`,
|
| 119 |
+
received: (name, a, b) => `✓ ${name} recuperado (${a}/${b})`,
|
| 120 |
+
bandLabel: (c, total) => `EL ÚLTIMO MENSAJE — ${c}/${total} fragmentos`,
|
| 121 |
+
recoverHint: "banda secreta 108–112 · sintoniza un fragmento y sostén el dial para recuperarlo",
|
| 122 |
+
fragmentsLabel: "FRAGMENTOS",
|
| 123 |
finaleTitle: "— EL ÚLTIMO MENSAJE —",
|
| 124 |
finaleEnd: "— fin de todas las transmisiones · gracias por escuchar —",
|
| 125 |
intro:
|
|
|
|
| 169 |
numberHint:
|
| 170 |
"Listen to the Morse · type the number sequence into the console to break in",
|
| 171 |
cipherBroken: "— CIPHER BROKEN — the 108-112 MHz band exists now —",
|
| 172 |
+
unlockHint: (code) => `code ${code} accepted · the dial grew to 108–112 MHz · recover all 5 fragments`,
|
| 173 |
downloading: (name, pct) => `↓ ${name} — ${pct}% · hold the dial steady`,
|
| 174 |
+
received: (name, a, b) => `✓ ${name} recovered (${a}/${b})`,
|
| 175 |
+
bandLabel: (c, total) => `THE LAST MESSAGE — ${c}/${total} fragments`,
|
| 176 |
+
recoverHint: "secret band 108–112 · tune a fragment and hold the dial steady to recover it",
|
| 177 |
+
fragmentsLabel: "FRAGMENTS",
|
| 178 |
finaleTitle: "— THE LAST MESSAGE —",
|
| 179 |
finaleEnd: "— end of all transmissions · thank you for listening —",
|
| 180 |
intro:
|
|
|
|
| 224 |
numberHint:
|
| 225 |
"Écoute le morse · tape la séquence de nombres dans la console pour entrer",
|
| 226 |
cipherBroken: "— CHIFFREMENT BRISÉ — la bande 108-112 MHz existe maintenant —",
|
| 227 |
+
unlockHint: (code) => `code ${code} accepté · le cadran s'étend à 108–112 MHz · récupère les 5 fragments`,
|
| 228 |
downloading: (name, pct) => `↓ ${name} — ${pct}% · tenez le cadran`,
|
| 229 |
+
received: (name, a, b) => `✓ ${name} récupéré (${a}/${b})`,
|
| 230 |
+
bandLabel: (c, total) => `LE DERNIER MESSAGE — ${c}/${total} fragments`,
|
| 231 |
+
recoverHint: "bande secrète 108–112 · accroche un fragment et tiens le cadran pour le récupérer",
|
| 232 |
+
fragmentsLabel: "FRAGMENTS",
|
| 233 |
finaleTitle: "— LE DERNIER MESSAGE —",
|
| 234 |
finaleEnd: "— fin de toutes les transmissions · merci d'avoir écouté —",
|
| 235 |
intro:
|
|
|
|
| 627 |
unlockCode = saved.code;
|
| 628 |
collectedFragments = new Set(saved.collected || []);
|
| 629 |
const ok = await loadHiddenBand(unlockCode);
|
| 630 |
+
if (ok) {
|
| 631 |
+
expandBand(false);
|
| 632 |
+
// re-populate the field log so the cipher/progress survive a refresh
|
| 633 |
+
logCipherSeq(MORSE_SEQUENCE);
|
| 634 |
+
logCipherCode(unlockCode);
|
| 635 |
+
renderNotes();
|
| 636 |
+
} else {
|
| 637 |
+
unlockCode = null;
|
| 638 |
+
}
|
| 639 |
}
|
| 640 |
} catch (_) {
|
| 641 |
/* corrupted state: ignored */
|
|
|
|
| 1365 |
inertiaActive = false;
|
| 1366 |
freqVel = 0;
|
| 1367 |
frequency = Math.round(frequency * 10) / 10;
|
| 1368 |
+
updateDialUI();
|
| 1369 |
+
scheduleTune();
|
| 1370 |
} else {
|
| 1371 |
updateDialUI();
|
| 1372 |
}
|
| 1373 |
}
|
| 1374 |
|
| 1375 |
+
// hidden band: the world is powering down, so the dial slips gently when you
|
| 1376 |
+
// let go — but the download advances whether you hold it by hand or let it
|
| 1377 |
+
// ride, so the minigame is actually winnable.
|
| 1378 |
+
if (unlockCode && !finaleStarted && frequency >= BASE_FREQ_MAX) {
|
| 1379 |
+
if (!isDragging && !knobDragging && !inertiaActive) {
|
| 1380 |
+
driftVel += (Math.random() - 0.5) * 0.006;
|
| 1381 |
+
driftVel = Math.max(-0.02, Math.min(0.02, driftVel)); // gentle slip
|
| 1382 |
+
frequency = Math.min(freqMax, Math.max(FREQ_MIN, frequency + driftVel * dt));
|
| 1383 |
+
updateDialUI();
|
| 1384 |
+
}
|
|
|
|
|
|
|
| 1385 |
updateDownload(dt);
|
| 1386 |
+
// safety: if every fragment is already recovered (e.g. after a refresh),
|
| 1387 |
+
// deliver the last message instead of leaving the player stuck at 5/5
|
| 1388 |
+
const tot = totalFragments();
|
| 1389 |
+
if (tot > 0 && collectedFragments.size >= tot) startFinale();
|
| 1390 |
+
} else {
|
| 1391 |
+
updateMissionHud(false);
|
| 1392 |
}
|
| 1393 |
|
| 1394 |
// under-damped needle spring (overshoot while settling)
|
|
|
|
| 1492 |
} else {
|
| 1493 |
freqVel = 0;
|
| 1494 |
setFrequency(frequency); // rounds to 0.1
|
| 1495 |
+
scheduleTune();
|
| 1496 |
}
|
| 1497 |
}
|
| 1498 |
|
|
|
|
| 1515 |
if (!candidates.length) return;
|
| 1516 |
const target = direction > 0 ? Math.min(...candidates) : Math.max(...candidates);
|
| 1517 |
setFrequency(target);
|
| 1518 |
+
scheduleTune();
|
| 1519 |
}
|
| 1520 |
|
| 1521 |
function onKeyDown(evt) {
|
|
|
|
| 1595 |
} else {
|
| 1596 |
freqVel = 0;
|
| 1597 |
setFrequency(frequency);
|
| 1598 |
+
scheduleTune();
|
| 1599 |
}
|
| 1600 |
}
|
| 1601 |
|
|
|
|
| 1615 |
if (freq === null) return;
|
| 1616 |
cancelIntro();
|
| 1617 |
setFrequency(freq);
|
| 1618 |
+
scheduleTune();
|
| 1619 |
}
|
| 1620 |
|
| 1621 |
// =====================================================================
|
|
|
|
| 1632 |
}
|
| 1633 |
|
| 1634 |
function cancelTune() {
|
| 1635 |
+
// Bumping the generation makes every in-flight for-await loop break, which
|
| 1636 |
+
// closes its stream client-side; the server self-terminates the superseded
|
| 1637 |
+
// generation via its own epoch. We deliberately do NOT call job.cancel():
|
| 1638 |
+
// gr.Server has no cancel route, so it just spams 404s and can wedge the
|
| 1639 |
+
// client after enough rapid retunes (the other half of "text stops coming").
|
| 1640 |
tuneGeneration += 1;
|
|
|
|
| 1641 |
currentJob = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
}
|
| 1643 |
|
| 1644 |
async function tuneNow() {
|
| 1645 |
+
if (introActive || finaleStarted) return; // never interrupt the last message
|
| 1646 |
const strength = signalStrength(frequency);
|
| 1647 |
const locked = strength >= 0.95;
|
| 1648 |
const stationFreq = stationFrequency(frequency);
|
|
|
|
| 2040 |
const sf = stationFrequency(frequency);
|
| 2041 |
const meta = stationMeta(sf);
|
| 2042 |
const strength = signalStrength(frequency);
|
| 2043 |
+
const total = totalFragments();
|
| 2044 |
+
const got = collectedFragments.size;
|
| 2045 |
|
| 2046 |
if (
|
| 2047 |
meta?.fragment &&
|
| 2048 |
sf !== null &&
|
| 2049 |
!collectedFragments.has(sf.toFixed(1)) &&
|
| 2050 |
+
strength >= 0.45
|
| 2051 |
) {
|
| 2052 |
if (downloadTarget !== sf) {
|
| 2053 |
downloadTarget = sf;
|
| 2054 |
downloadProgress = 0;
|
| 2055 |
}
|
| 2056 |
+
downloadProgress = Math.min(1, downloadProgress + dt / 5); // ~5s to recover
|
| 2057 |
+
updateMissionHud(true, got, total, downloadProgress, stationDisplayName(meta));
|
| 2058 |
if (hint) {
|
| 2059 |
hint.textContent = t().downloading(
|
| 2060 |
stationDisplayName(meta),
|
|
|
|
| 2065 |
collectedFragments.add(sf.toFixed(1));
|
| 2066 |
persistProgress();
|
| 2067 |
playJingle(meta.voice);
|
| 2068 |
+
logStation(sf, stationDisplayName(meta), "fragment"); // recorded in the field log
|
| 2069 |
downloadTarget = null;
|
| 2070 |
downloadProgress = 0;
|
| 2071 |
+
const newGot = collectedFragments.size;
|
| 2072 |
+
if (hint) hint.textContent = t().received(stationDisplayName(meta), newGot, total);
|
| 2073 |
+
renderNotes(true);
|
| 2074 |
+
updateMissionHud(true, newGot, total, 0, null);
|
| 2075 |
+
if (total > 0 && newGot >= total) setTimeout(startFinale, 1600);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2076 |
}
|
| 2077 |
+
} else {
|
| 2078 |
+
if (downloadTarget !== null) {
|
| 2079 |
+
// forgiving decay: a brief slip off the fragment doesn't wipe progress
|
| 2080 |
+
downloadProgress = Math.max(0, downloadProgress - dt * 0.12);
|
| 2081 |
+
if (downloadProgress === 0) downloadTarget = null;
|
| 2082 |
+
}
|
| 2083 |
+
updateMissionHud(true, got, total, downloadProgress, null);
|
| 2084 |
+
}
|
| 2085 |
+
}
|
| 2086 |
+
|
| 2087 |
+
// On-screen objective + progress for the fragment hunt (so the endgame is clear)
|
| 2088 |
+
function updateMissionHud(show, got = 0, total = 0, progress = 0, name = null) {
|
| 2089 |
+
if (!missionHud) return;
|
| 2090 |
+
if (!show || total <= 0) {
|
| 2091 |
+
missionHud.classList.add("hidden");
|
| 2092 |
+
return;
|
| 2093 |
+
}
|
| 2094 |
+
missionHud.classList.remove("hidden");
|
| 2095 |
+
if (missionHudText) {
|
| 2096 |
+
missionHudText.textContent = name
|
| 2097 |
+
? `${t().bandLabel(got, total)} · ↓ ${name} ${Math.round(progress * 100)}%`
|
| 2098 |
+
: `${t().bandLabel(got, total)} · ${t().recoverHint}`;
|
| 2099 |
+
}
|
| 2100 |
+
if (missionHudFill) missionHudFill.style.width = `${Math.round(progress * 100)}%`;
|
| 2101 |
+
if (missionHudPips) {
|
| 2102 |
+
if (missionHudPips.children.length !== total) {
|
| 2103 |
+
missionHudPips.textContent = "";
|
| 2104 |
+
for (let i = 0; i < total; i++) missionHudPips.appendChild(document.createElement("span"));
|
| 2105 |
+
}
|
| 2106 |
+
[...missionHudPips.children].forEach((p, i) => p.classList.toggle("on", i < got));
|
| 2107 |
}
|
| 2108 |
}
|
| 2109 |
|
|
|
|
| 2318 |
`<span class="nc-code">${escapeHtml(cipherCode)}</span>`;
|
| 2319 |
notesCipherEl.appendChild(d);
|
| 2320 |
}
|
| 2321 |
+
const fragTotal = totalFragments();
|
| 2322 |
+
if (fragTotal > 0) {
|
| 2323 |
+
const d = document.createElement("div");
|
| 2324 |
+
d.textContent = `${t().fragmentsLabel}: ${collectedFragments.size}/${fragTotal}`;
|
| 2325 |
+
notesCipherEl.appendChild(d);
|
| 2326 |
+
}
|
| 2327 |
}
|
| 2328 |
}
|
| 2329 |
}
|