Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e046cf19ec | |||
| 71aaee391f | |||
| c915fff113 | |||
| 193530c03e | |||
| 3fa3acc36f | |||
| 13219f3a6c | |||
| 0ca6802475 | |||
| caf64447b8 |
@@ -110,4 +110,17 @@ jobs:
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "${{ steps.version-check.outputs.TAG }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
--draft=false \
|
||||
--latest \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
# Gitea mirror force-pushes tags which can reset releases to draft.
|
||||
# Re-publish any drafts on every run to counteract this.
|
||||
- name: Publish any draft releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release list --repo "$GITHUB_REPOSITORY" --json tagName,isDraft --jq '.[] | select(.isDraft) | .tagName' | while read -r tag; do
|
||||
echo "Publishing draft release: $tag"
|
||||
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
done
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "krunker-civilian-client",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.0",
|
||||
"description": "Cross-platform Krunker game client",
|
||||
"main": "dist/main/index.js",
|
||||
"homepage": "https://github.com/bigjakk/Krunker-Civilian-Client",
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
#
|
||||
# Generate Markdown release notes from conventional commits.
|
||||
# Usage: ./scripts/generate-release-notes.sh <tag> [prev-ref]
|
||||
# e.g. ./scripts/generate-release-notes.sh v0.7.0
|
||||
# e.g. ./scripts/generate-release-notes.sh v0.7.0 abc123f
|
||||
#
|
||||
# Skips version bumps, CI-only changes, and other noise.
|
||||
# If prev-ref is not provided, tries git describe to find previous tag.
|
||||
# If no previous ref is found, includes all commits up to HEAD.
|
||||
# Uses HEAD as the endpoint (tag may not exist in git yet).
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
@@ -21,32 +19,32 @@ fi
|
||||
|
||||
if [ -n "$PREV_REF" ]; then
|
||||
RANGE="${PREV_REF}..HEAD"
|
||||
COMPARE_TEXT="**Full changelog**: \`${PREV_REF}...${TAG}\`"
|
||||
else
|
||||
RANGE="HEAD"
|
||||
COMPARE_TEXT="**Initial release**"
|
||||
fi
|
||||
|
||||
# Collect commits into temp files by category
|
||||
TMPDIR_NOTES=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR_NOTES"' EXIT
|
||||
|
||||
for prefix in feat fix refactor perf docs test chore other; do
|
||||
for prefix in feat fix refactor perf other; do
|
||||
: > "${TMPDIR_NOTES}/${prefix}"
|
||||
done
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
|
||||
# Skip version bump commits (e.g. "v0.6.1", "v0.6.2 — description")
|
||||
[[ "$line" =~ ^v[0-9] ]] && continue
|
||||
|
||||
# Skip chore/docs/test/ci commits — not user-facing
|
||||
[[ "$line" =~ ^(chore|docs|test|ci)(\(.*\))?: ]] && continue
|
||||
|
||||
MATCHED=false
|
||||
for prefix in feat fix refactor perf docs test chore; do
|
||||
for prefix in feat fix refactor perf; do
|
||||
if [[ "$line" =~ ^${prefix}(\(.*\))?:\ (.+)$ ]]; then
|
||||
SCOPE="${BASH_REMATCH[1]}"
|
||||
MSG="${BASH_REMATCH[2]}"
|
||||
if [ -n "$SCOPE" ]; then
|
||||
echo "- **${SCOPE}**: ${MSG}" >> "${TMPDIR_NOTES}/${prefix}"
|
||||
else
|
||||
echo "- ${MSG}" >> "${TMPDIR_NOTES}/${prefix}"
|
||||
fi
|
||||
MATCHED=true
|
||||
break
|
||||
fi
|
||||
@@ -59,36 +57,34 @@ done < <(git log --format="%s" "$RANGE" 2>/dev/null)
|
||||
# Section display names
|
||||
section_title() {
|
||||
case "$1" in
|
||||
feat) echo "Features" ;;
|
||||
fix) echo "Bug Fixes" ;;
|
||||
refactor) echo "Refactoring" ;;
|
||||
perf) echo "Performance" ;;
|
||||
docs) echo "Documentation" ;;
|
||||
test) echo "Tests" ;;
|
||||
chore) echo "Chores" ;;
|
||||
feat) echo "## New" ;;
|
||||
fix) echo "## Fixes" ;;
|
||||
refactor) echo "## Improvements" ;;
|
||||
perf) echo "## Performance" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Build output
|
||||
echo "# KCC ${TAG}"
|
||||
echo ""
|
||||
# Build output — only user-facing sections
|
||||
HAS_CONTENT=false
|
||||
|
||||
for prefix in feat fix refactor perf docs test chore; do
|
||||
for prefix in feat fix refactor perf; do
|
||||
if [ -s "${TMPDIR_NOTES}/${prefix}" ]; then
|
||||
echo "## $(section_title "$prefix")"
|
||||
section_title "$prefix"
|
||||
echo ""
|
||||
cat "${TMPDIR_NOTES}/${prefix}"
|
||||
echo ""
|
||||
HAS_CONTENT=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -s "${TMPDIR_NOTES}/other" ]; then
|
||||
echo "## Other Changes"
|
||||
echo "## Other"
|
||||
echo ""
|
||||
cat "${TMPDIR_NOTES}/other"
|
||||
echo ""
|
||||
HAS_CONTENT=true
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "${COMPARE_TEXT}"
|
||||
if [ "$HAS_CONTENT" = false ]; then
|
||||
echo "Bug fixes and improvements."
|
||||
fi
|
||||
|
||||
+42
-1
@@ -684,5 +684,46 @@ export const BP_CLAIM_ALL_CSS = `
|
||||
#claimAllBtn.disabled { opacity: 0.4; pointer-events: none; }
|
||||
`;
|
||||
|
||||
// ── Rank progress tracker CSS ──
|
||||
export const RANK_TRACKER_CSS = `
|
||||
#kpc-elo-tracker { width: 100%; margin: 8px 0; }
|
||||
.kpc-elo-info-row { display: flex; align-items: center; gap: 8px; }
|
||||
.kpc-rank-container { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 12px; color: #ccc; }
|
||||
.kpc-elo-rank-img { width: 20px; height: 20px; }
|
||||
.kpc-elo-bar-bg { flex: 1; height: 14px; background: rgba(255,255,255,0.1); border-radius: 7px; position: relative; overflow: hidden; }
|
||||
.kpc-elo-bar-fill { height: 100%; background: linear-gradient(90deg, #388E3C, #4CAF50); border-radius: 7px; transition: width 0.3s; }
|
||||
.kpc-elo-bar-text { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
|
||||
#kpc-rank-list-btn { position: absolute; bottom: 8px; right: 8px; cursor: pointer; padding: 6px 14px; border-radius: 6px; font-size: 12px; background: rgba(76,175,80,0.3); color: #4CAF50; border: 1px solid rgba(76,175,80,0.4); z-index: 1; }
|
||||
#kpc-rank-list-btn:hover { background: rgba(76,175,80,0.5); color: #fff; }
|
||||
#kpc-rank-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.75); z-index: 9998; display: flex; justify-content: center; align-items: center; }
|
||||
.kpc-rank-popup { background: #1a1a2e; border-radius: 12px; padding: 20px; min-width: 340px; max-width: 500px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||
.kpc-rank-popup-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.kpc-rank-popup-header h2 { margin: 0; color: #fff; font-size: 16px; }
|
||||
.kpc-rank-popup-close { cursor: pointer; color: #888; font-size: 18px; padding: 4px 8px; }
|
||||
.kpc-rank-popup-close:hover { color: #fff; }
|
||||
.kpc-rank-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 60vh; overflow-y: auto; }
|
||||
.kpc-rank-grid-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: rgba(255,255,255,0.05); border-radius: 6px; }
|
||||
.kpc-rank-grid-item img { width: 28px; height: 28px; }
|
||||
.kpc-rank-name { font-size: 13px; font-weight: 600; }
|
||||
.kpc-rank-elo { font-size: 11px; color: #888; }
|
||||
|
||||
/* Ranked queue button in ranked menu footer */
|
||||
#kpc-ranked-queue-btn {
|
||||
background-color: #5ce05a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
#kpc-ranked-queue-btn:hover { background-color: #4bc94a; }
|
||||
`;
|
||||
|
||||
/** Pre-concatenated CSS for single-call injection (excludes HIDE_ADS_CSS which is separate) */
|
||||
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}\n${HP_COUNTER_CSS}\n${BP_CLAIM_ALL_CSS}`;
|
||||
export const ALL_CLIENT_CSS = `${CLIENT_SETTINGS_CSS}\n${MATCHMAKER_SETTINGS_CSS}\n${TRANSLATOR_CSS}\n${ALT_MANAGER_CSS}\n${HP_COUNTER_CSS}\n${BP_CLAIM_ALL_CSS}\n${RANK_TRACKER_CSS}`;
|
||||
|
||||
+7
-1
@@ -15,6 +15,7 @@ import { showUpdateWindow } from './update-window';
|
||||
import { DiscordRPC } from './discord-rpc';
|
||||
import { listThemes, getThemeCSS, listLoadingThemes, getLoadingScreenCSS } from './css-themes';
|
||||
import { TabManager } from './tab-manager';
|
||||
import { openRankedQueue } from './ranked-queue';
|
||||
|
||||
// ── App version for API calls ──
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -192,7 +193,7 @@ app.whenReady().then(async () => {
|
||||
if (!cancelled && !updateWin.isDestroyed()) {
|
||||
sendProgress(`Downloading update... ${pct}%`, pct);
|
||||
}
|
||||
});
|
||||
}, update.sha256);
|
||||
|
||||
if (!cancelled) {
|
||||
sendProgress('Installing update...', 100);
|
||||
@@ -651,6 +652,11 @@ async function launchApp(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Ranked queue IPC handler ──
|
||||
ipcMain.on('open-ranked-queue', (_e, token: string, region: string, allRegions: boolean) => {
|
||||
openRankedQueue(token, region, allRegions);
|
||||
});
|
||||
|
||||
// ── Discord Rich Presence IPC handler ──
|
||||
ipcMain.on('discord-update', (_e, activity: any) => {
|
||||
discordRpc?.setActivity(activity);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,516 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { QUEUE_NOTIFICATION_AUDIO } from './ranked-queue-audio';
|
||||
|
||||
let queueWindow: BrowserWindow | null = null;
|
||||
|
||||
const RANKED_QUEUE_WS = 'wss://gamefrontend.svc.krunker.io/v1/matchmaking/queue';
|
||||
|
||||
const RANKED_MAPS: Record<string, { number: number; image: string }> = {
|
||||
sandstorm_v3: { number: 2, image: 'https://assets.krunker.io/img/maps/map_2.png' },
|
||||
undergrowth: { number: 4, image: 'https://assets.krunker.io/img/maps/map_4.png' },
|
||||
industry: { number: 11, image: 'https://assets.krunker.io/img/maps/map_11.png' },
|
||||
site: { number: 14, image: 'https://assets.krunker.io/img/maps/map_14.png' },
|
||||
bureau: { number: 17, image: 'https://assets.krunker.io/img/maps/map_17.png' },
|
||||
burg_new: { number: 0, image: 'https://assets.krunker.io/img/maps/map_0.png' },
|
||||
eterno_sim: { number: 39, image: 'https://assets.krunker.io/img/maps/map_39.png' },
|
||||
};
|
||||
|
||||
const RANKED_REGIONS: Record<string, string> = {
|
||||
na: 'North America',
|
||||
eu: 'Europe',
|
||||
as: 'Asia',
|
||||
};
|
||||
|
||||
const QUEUE_CSS = `
|
||||
* { user-select: none; margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Trebuchet MS", sans-serif;
|
||||
background: #0d0d0d;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.queuer-container {
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
padding: 40px 52px;
|
||||
max-width: 1000px;
|
||||
width: 90vw;
|
||||
border: 2px solid #2a2a2a;
|
||||
border-top: 3px solid #06b6d4;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.main-content { display: flex; align-items: center; gap: 56px; }
|
||||
.left-section { flex: 1; display: flex; flex-direction: column; gap: 24px; }
|
||||
.status-area {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
position: relative; padding-left: 18px;
|
||||
}
|
||||
.status-area::before {
|
||||
content: ""; position: absolute; left: 0;
|
||||
width: 8px; height: 8px; background: #666;
|
||||
border-radius: 50%; transition: background 0.3s ease;
|
||||
}
|
||||
.status-area.active::before {
|
||||
background: #06b6d4;
|
||||
box-shadow: 0 0 12px rgba(6, 182, 212, 0.6);
|
||||
}
|
||||
#queueStatus {
|
||||
font-size: 14px; font-weight: 600; color: #666;
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
#queueStatus.active { color: #06b6d4; }
|
||||
.timer-display {
|
||||
font-size: 52px; font-weight: 700; color: #fff;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: 0.5px;
|
||||
padding: 12px 16px; background: #222;
|
||||
border-left: 3px solid #06b6d4; border-radius: 2px;
|
||||
}
|
||||
.region-controls { display: flex; gap: 12px; }
|
||||
.region-option { position: relative; }
|
||||
.region-option input { display: none; }
|
||||
.region-option label {
|
||||
display: block; padding: 12px 24px; background: #222;
|
||||
border: 2px solid #2d2d2d; border-radius: 4px;
|
||||
color: #888; font-size: 16px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.region-option label:hover { background: #2a2a2a; border-color: #3a3a3a; }
|
||||
.region-option input:checked + label {
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border-color: #06b6d4; color: #06b6d4;
|
||||
}
|
||||
.divider { width: 1px; height: 120px; background: #2a2a2a; }
|
||||
.right-section { display: flex; flex-direction: column; gap: 14px; }
|
||||
.btn {
|
||||
padding: 16px 42px; border: 2px solid transparent;
|
||||
font-size: 20px; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s ease; font-family: "Trebuchet MS", sans-serif;
|
||||
text-transform: uppercase; letter-spacing: 1px; border-radius: 4px;
|
||||
}
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-secondary { background: #222; color: #999; border-color: #2a2a2a; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #2a2a2a; border-color: #3a3a3a; }
|
||||
.btn-primary { background: #06b6d4; color: #fff; border-color: #06b6d4; }
|
||||
.btn-primary:hover:not(:disabled) { background: #0ea5ca; border-color: #0ea5ca; }
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.98); }
|
||||
.btn-primary.in-queue { background: #222; border-color: #06b6d4; color: #06b6d4; }
|
||||
.btn-primary.in-queue:hover:not(:disabled) { background: rgba(6, 182, 212, 0.1); }
|
||||
.overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9); display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
opacity: 0; visibility: hidden; transition: all 0.3s ease; z-index: 1000;
|
||||
}
|
||||
.overlay.active { opacity: 1; visibility: visible; }
|
||||
.popup {
|
||||
background: #1a1a1a; border: 2px solid #2a2a2a;
|
||||
border-top: 3px solid #06b6d4; max-width: 560px; width: 90vw;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.8);
|
||||
text-align: center; transform: scale(0.95);
|
||||
transition: transform 0.3s ease; border-radius: 4px;
|
||||
}
|
||||
.overlay.active .popup { transform: scale(1); }
|
||||
.popup h2 {
|
||||
margin-top: 12px; font-size: 32px; font-weight: 700;
|
||||
color: #06b6d4; text-transform: uppercase; letter-spacing: 1.5px;
|
||||
}
|
||||
.popup-content { margin: 20px 0; }
|
||||
.popup-content p {
|
||||
font-size: 15px; color: #888; margin-bottom: 12px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600;
|
||||
}
|
||||
.region-found {
|
||||
font-size: 18px; font-weight: 700; color: #fff;
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
display: inline-block; padding: 12px 24px;
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
border: 2px solid #06b6d4; border-radius: 4px;
|
||||
}
|
||||
.countdown-large {
|
||||
font-size: 48px; font-weight: 700; color: #06b6d4;
|
||||
margin: 16px 0; font-variant-numeric: tabular-nums; line-height: 1;
|
||||
}
|
||||
#matchFoundMessage {
|
||||
margin-top: 12px; font-size: 20px; color: #fff;
|
||||
text-align: center; width: 400px; margin-left: auto; margin-right: auto;
|
||||
}
|
||||
#closeButton {
|
||||
position: absolute; right: 0; top: 0;
|
||||
margin: 10px 20px 0 0; font-size: 20px; cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
function buildMapsJson(): string {
|
||||
const entries: string[] = [];
|
||||
for (const [name, data] of Object.entries(RANKED_MAPS)) {
|
||||
entries.push(`${JSON.stringify(name)}: { number: ${data.number}, image: ${JSON.stringify(data.image)} }`);
|
||||
}
|
||||
return `{ ${entries.join(', ')} }`;
|
||||
}
|
||||
|
||||
function buildRegionsJson(): string {
|
||||
return JSON.stringify(RANKED_REGIONS);
|
||||
}
|
||||
|
||||
function buildRegionCheckboxes(): string {
|
||||
return Object.entries(RANKED_REGIONS).map(([code, name]) => {
|
||||
const inputId = code === 'as' ? 'asia' : code;
|
||||
return `<div class="region-option"><input type="checkbox" id="${inputId}" value="${code}"><label for="${inputId}">${name === 'North America' ? 'NA' : name === 'Europe' ? 'EU' : 'Asia'}</label></div>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function buildQueueScript(token: string, region: string, allRegions: boolean): string {
|
||||
return `
|
||||
let isQueued = false;
|
||||
let queueStartTime = null;
|
||||
let queueInterval = null;
|
||||
let queueConnection = null;
|
||||
let countdownInterval = null;
|
||||
let isConnecting = false;
|
||||
let audioContext = null;
|
||||
let notificationBuffer = null;
|
||||
let currentSource = null;
|
||||
let audioInitialized = false;
|
||||
const selectedMaps = new Set();
|
||||
|
||||
const WS_URL = ${JSON.stringify(RANKED_QUEUE_WS)};
|
||||
const INIT_TOKEN = ${JSON.stringify(token)};
|
||||
const INIT_REGION = ${JSON.stringify(region)};
|
||||
const INIT_ALL_REGIONS = ${JSON.stringify(allRegions)};
|
||||
const maps = ${buildMapsJson()};
|
||||
const regions = ${buildRegionsJson()};
|
||||
|
||||
const queueStatus = document.getElementById('queueStatus');
|
||||
const statusArea = document.getElementById('statusArea');
|
||||
const queueTimerDisplay = document.getElementById('queueTimerDisplay');
|
||||
const regionCheckboxes = document.getElementById('regionCheckboxes');
|
||||
const matchPopupOverlay = document.getElementById('matchPopupOverlay');
|
||||
const countdownTimer = document.getElementById('countDownTimer');
|
||||
const foundRegion = document.getElementById('foundRegion');
|
||||
const queueButton = document.getElementById('queueButton');
|
||||
const closeButton = document.getElementById('closeButton');
|
||||
|
||||
const base64String = ${JSON.stringify(QUEUE_NOTIFICATION_AUDIO)};
|
||||
|
||||
function saveSettings() {
|
||||
const selectedRegions = Array.from(document.querySelectorAll('#regionCheckboxes input:checked')).map(el => el.value);
|
||||
localStorage.setItem('queue_selectedRegions', JSON.stringify(selectedRegions));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const savedRegions = localStorage.getItem('queue_selectedRegions');
|
||||
if (savedRegions) {
|
||||
for (const regionId of JSON.parse(savedRegions)) {
|
||||
const checkbox = document.getElementById(regionId === 'as' ? 'asia' : regionId);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
} else if (INIT_REGION) {
|
||||
const checkbox = document.getElementById(INIT_REGION === 'as' ? 'asia' : INIT_REGION);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
if (INIT_ALL_REGIONS) {
|
||||
for (const el of document.querySelectorAll('#regionCheckboxes input')) el.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const base64ToArrayBuffer = (b64) => {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
};
|
||||
|
||||
async function initializeAudio() {
|
||||
audioContext = new AudioContext();
|
||||
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||
const arrayBuffer = base64ToArrayBuffer(base64String);
|
||||
notificationBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
audioInitialized = true;
|
||||
}
|
||||
|
||||
function playNotificationSound() {
|
||||
if (!notificationBuffer || !audioContext) return;
|
||||
try {
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = notificationBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
currentSource = source;
|
||||
} catch (e) { console.error('Audio play error:', e); }
|
||||
}
|
||||
|
||||
function stopNotificationSound() {
|
||||
if (currentSource) {
|
||||
try { currentSource.stop(); currentSource.disconnect(); } catch {}
|
||||
currentSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
|
||||
const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
|
||||
const s = String(Math.floor(seconds % 60)).padStart(2, '0');
|
||||
return h + ':' + m + ':' + s;
|
||||
}
|
||||
|
||||
function updateCooldownTimer(ms) {
|
||||
const endTime = Date.now() + ms;
|
||||
function updateDisplay() {
|
||||
const remaining = Math.ceil((endTime - Date.now()) / 1000);
|
||||
if (remaining <= 0) {
|
||||
queueStatus.textContent = 'Ready';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
queueButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
queueStatus.textContent = 'Cooldown: ' + formatTime(remaining);
|
||||
queueButton.disabled = true;
|
||||
setTimeout(updateDisplay, 1000);
|
||||
}
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function updateQueueTimer() {
|
||||
if (queueStartTime) {
|
||||
const elapsed = Math.floor((Date.now() - queueStartTime) / 1000);
|
||||
queueTimerDisplay.textContent = formatTime(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
function startQueue() {
|
||||
const selectedRegions = Array.from(document.querySelectorAll('#regionCheckboxes input:checked')).map(el => el.value);
|
||||
|
||||
if (selectedRegions.length === 0) {
|
||||
queueStatus.textContent = 'Select at least one region';
|
||||
queueButton.disabled = false;
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
if (selectedMaps.size === 0) {
|
||||
queueStatus.textContent = 'Select at least one map';
|
||||
queueButton.disabled = false;
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = WS_URL + '?token=' + INIT_TOKEN + '&maps=' + Array.from(selectedMaps).join(',') + '®ions=' + selectedRegions.join(',');
|
||||
|
||||
try {
|
||||
queueConnection = new WebSocket(wsUrl);
|
||||
} catch (error) {
|
||||
console.error('WebSocket creation error:', error);
|
||||
queueStatus.textContent = 'Connection failed';
|
||||
queueButton.disabled = false;
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
queueConnection.onerror = (error) => {
|
||||
console.error('queueConnection error:', error);
|
||||
queueStatus.textContent = 'Connection error';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
queueButton.disabled = false;
|
||||
};
|
||||
|
||||
queueConnection.onopen = () => {
|
||||
isQueued = true;
|
||||
isConnecting = false;
|
||||
queueStartTime = Date.now();
|
||||
queueButton.textContent = 'Leave Queue';
|
||||
queueButton.classList.add('in-queue');
|
||||
queueStatus.textContent = 'In queue';
|
||||
queueStatus.classList.add('active');
|
||||
statusArea.classList.add('active');
|
||||
updateQueueTimer();
|
||||
queueInterval = setInterval(updateQueueTimer, 1000);
|
||||
queueButton.disabled = false;
|
||||
};
|
||||
|
||||
queueConnection.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case 'QUEUE_STATUS':
|
||||
if (data.payload.status === 'MATCHED')
|
||||
matchFound(data.payload.assignment.extensions.map.trim(), data.payload.assignment.extensions.region);
|
||||
break;
|
||||
case 'ERROR':
|
||||
if (data.payload.code === 'COOLDOWN') {
|
||||
queueConnection.close();
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
queueButton.disabled = false;
|
||||
updateCooldownTimer(data.payload.payload.cooldown);
|
||||
}
|
||||
break;
|
||||
case 'INTERNAL_ERROR':
|
||||
queueConnection.close();
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
queueButton.disabled = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
queueConnection.onclose = () => {
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
clearInterval(queueInterval);
|
||||
queueButton.textContent = 'Start Queue';
|
||||
queueButton.classList.remove('in-queue');
|
||||
queueStatus.textContent = 'Ready';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
queueTimerDisplay.textContent = '00:00:00';
|
||||
queueButton.disabled = false;
|
||||
};
|
||||
}
|
||||
|
||||
function matchFound(map, region) {
|
||||
playNotificationSound();
|
||||
matchPopupOverlay.classList.add('active');
|
||||
region = region.slice(2);
|
||||
const regionName = regions[region] || region;
|
||||
let foundMapName = 'unknown';
|
||||
for (const [mapName, mapData] of Object.entries(maps)) {
|
||||
if (mapData.number === parseInt(map, 10)) { foundMapName = mapName; break; }
|
||||
}
|
||||
foundRegion.textContent = regionName + ', ' + foundMapName;
|
||||
|
||||
const duration = 60;
|
||||
const startTime = Date.now();
|
||||
countdownInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const remaining = Math.max(0, duration - elapsed);
|
||||
countdownTimer.textContent = formatTime(remaining);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
matchPopupOverlay.classList.remove('active');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
isQueued = false;
|
||||
isConnecting = false;
|
||||
clearInterval(queueInterval);
|
||||
queueButton.textContent = 'Start Queue';
|
||||
queueButton.classList.remove('in-queue');
|
||||
queueStatus.textContent = 'Ready';
|
||||
queueStatus.classList.remove('active');
|
||||
statusArea.classList.remove('active');
|
||||
queueTimerDisplay.textContent = '00:00:00';
|
||||
queueButton.disabled = false;
|
||||
}
|
||||
|
||||
// Queue button
|
||||
queueButton.onclick = async () => {
|
||||
if (isConnecting) return;
|
||||
queueButton.disabled = true;
|
||||
isConnecting = true;
|
||||
if (isQueued) {
|
||||
queueConnection.close();
|
||||
} else {
|
||||
if (!audioInitialized) await initializeAudio();
|
||||
startQueue();
|
||||
}
|
||||
};
|
||||
|
||||
// Close match popup
|
||||
closeButton.onclick = () => {
|
||||
matchPopupOverlay.classList.remove('active');
|
||||
stopNotificationSound();
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
};
|
||||
|
||||
// Region checkbox changes
|
||||
for (const sel of regionCheckboxes.querySelectorAll('input')) {
|
||||
sel.onclick = () => {
|
||||
if (isQueued && queueConnection) queueConnection.close();
|
||||
saveSettings();
|
||||
};
|
||||
}
|
||||
|
||||
// Init — select all maps unconditionally (ranked doesn't allow map choice)
|
||||
for (const data of Object.values(maps)) selectedMaps.add(data.number);
|
||||
loadSettings();
|
||||
`;
|
||||
}
|
||||
|
||||
function buildQueueHtml(token: string, region: string, allRegions: boolean): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ranked Queue</title>
|
||||
<style>${QUEUE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="queuer-container">
|
||||
<div class="main-content">
|
||||
<div class="left-section">
|
||||
<div class="status-area" id="statusArea">
|
||||
<span id="queueStatus">Ready</span>
|
||||
</div>
|
||||
<div class="timer-display" id="queueTimerDisplay">00:00:00</div>
|
||||
<div class="region-controls" id="regionCheckboxes">
|
||||
${buildRegionCheckboxes()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="right-section">
|
||||
<button type="button" class="btn btn-primary" id="queueButton">Start Queue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay" id="matchPopupOverlay">
|
||||
<div class="popup">
|
||||
<h2>Match Found</h2>
|
||||
<div class="popup-content">
|
||||
<div id="closeButton">X</div>
|
||||
<div class="region-found" id="foundRegion">Region: </div>
|
||||
<div id="matchFoundMessage">open the client and rejoin the game from the ranked menu</div>
|
||||
<div class="countdown-large" id="countDownTimer">00:00:60</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>${buildQueueScript(token, region, allRegions)}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function openRankedQueue(token: string, region: string, allRegions: boolean): void {
|
||||
if (queueWindow && !queueWindow.isDestroyed()) {
|
||||
queueWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 850,
|
||||
height: 350,
|
||||
resizable: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0d0d0d',
|
||||
title: 'Ranked Queue',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
win.removeMenu();
|
||||
|
||||
queueWindow = win;
|
||||
win.on('closed', () => { queueWindow = null; });
|
||||
|
||||
const html = buildQueueHtml(token, region, allRegions);
|
||||
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
||||
}
|
||||
+41
-5
@@ -1,5 +1,6 @@
|
||||
import { get as httpsGet } from 'https';
|
||||
import { createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
|
||||
import { createReadStream, createWriteStream, renameSync, unlinkSync, existsSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { electronLog } from './logger';
|
||||
@@ -8,6 +9,7 @@ export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
fileSize: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (percent: number) => void;
|
||||
@@ -104,7 +106,7 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
return;
|
||||
}
|
||||
|
||||
const assets: Array<{ name: string; browser_download_url: string; size: number }> = release.assets || [];
|
||||
const assets: Array<{ name: string; browser_download_url: string; size: number; digest: string }> = release.assets || [];
|
||||
const setupAsset = assets.find((a) => UPDATE_CONFIG.assetPattern.test(a.name));
|
||||
if (!setupAsset) {
|
||||
electronLog.error('[KCC-Update] No Setup.exe asset found in release', remoteVersion);
|
||||
@@ -119,11 +121,20 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
return;
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| Download:', setupAsset.browser_download_url, '| Size:', setupAsset.size);
|
||||
// Extract SHA-256 digest from GitHub API (format: "sha256:<hex>")
|
||||
const sha256 = (setupAsset.digest || '').replace(/^sha256:/i, '');
|
||||
if (!sha256) {
|
||||
electronLog.error('[KCC-Update] No SHA-256 digest found for asset');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
electronLog.log('[KCC-Update] Update available:', remoteVersion, '| SHA-256:', sha256.substring(0, 16) + '...');
|
||||
resolve({
|
||||
version: remoteVersion,
|
||||
downloadUrl: setupAsset.browser_download_url,
|
||||
fileSize: setupAsset.size,
|
||||
sha256,
|
||||
});
|
||||
} catch (err) {
|
||||
electronLog.error('[KCC-Update] Failed to parse release data:', err);
|
||||
@@ -149,7 +160,22 @@ export function checkForUpdate(currentVersion: string): Promise<UpdateInfo | nul
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback): Promise<void> {
|
||||
function verifyChecksum(filePath: string, expectedSha256: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => {
|
||||
const actual = hash.digest('hex');
|
||||
electronLog.log('[KCC-Update] SHA-256 expected:', expectedSha256);
|
||||
electronLog.log('[KCC-Update] SHA-256 actual: ', actual);
|
||||
resolve(actual === expectedSha256);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadUpdate(url: string, destPath: string, onProgress: ProgressCallback, expectedSha256?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = destPath + '.tmp';
|
||||
|
||||
@@ -194,8 +220,18 @@ export function downloadUpdate(url: string, destPath: string, onProgress: Progre
|
||||
res.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
file.close(async () => {
|
||||
try {
|
||||
if (expectedSha256) {
|
||||
const valid = await verifyChecksum(tmpPath, expectedSha256);
|
||||
if (!valid) {
|
||||
electronLog.error('[KCC-Update] Checksum mismatch — file may be corrupted or tampered');
|
||||
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
reject(new Error('SHA-256 checksum mismatch'));
|
||||
return;
|
||||
}
|
||||
electronLog.log('[KCC-Update] Checksum verified');
|
||||
}
|
||||
if (existsSync(destPath)) unlinkSync(destPath);
|
||||
renameSync(tmpPath, destPath);
|
||||
resolve();
|
||||
|
||||
+194
-4
@@ -1,5 +1,5 @@
|
||||
// ── Hardpoint Enemy Counter ──
|
||||
// Displays enemy capture points being scored in Hardpoint mode.
|
||||
// ── Competitive features: Hardpoint enemy counter + Rank progress tracker + Ranked queue ──
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
let hpObserver: MutationObserver | null = null;
|
||||
let hpCounterEl: HTMLElement | null = null;
|
||||
@@ -8,6 +8,8 @@ let hpEnemyOBJ = 0;
|
||||
let hpTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let hpCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Hardpoint Enemy Counter ──
|
||||
|
||||
function processTeamScores(): void {
|
||||
const teams = document.querySelectorAll('#tScoreC1, #tScoreC2');
|
||||
for (const team of teams) {
|
||||
@@ -49,7 +51,7 @@ function setupHPDisplay(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function initHPCounter(): void {
|
||||
function startHPCounter(): void {
|
||||
hpCheckInterval = setInterval(() => {
|
||||
if (document.querySelector('.cmpTmHed')) {
|
||||
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
|
||||
@@ -58,7 +60,7 @@ export function initHPCounter(): void {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
export function destroyHPCounter(): void {
|
||||
function stopHPCounter(): void {
|
||||
if (hpCheckInterval) { clearInterval(hpCheckInterval); hpCheckInterval = null; }
|
||||
if (hpObserver) { hpObserver.disconnect(); hpObserver = null; }
|
||||
if (hpCounterEl) { hpCounterEl.remove(); hpCounterEl = null; }
|
||||
@@ -66,3 +68,191 @@ export function destroyHPCounter(): void {
|
||||
hpPointCounter = null;
|
||||
hpEnemyOBJ = 0;
|
||||
}
|
||||
|
||||
export function initHPCounter(): void { startHPCounter(); }
|
||||
export function destroyHPCounter(): void { stopHPCounter(); }
|
||||
|
||||
export function setHPCounterEnabled(enabled: boolean): void {
|
||||
stopHPCounter();
|
||||
if (enabled) startHPCounter();
|
||||
}
|
||||
|
||||
// ── Rank Progress Tracker ──
|
||||
|
||||
interface RankInfo {
|
||||
rank: string;
|
||||
elo: number | null;
|
||||
color: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
const RANKS: RankInfo[] = [
|
||||
{ rank: 'Unranked', elo: null, color: '#FFFFFF', image: 'rank_unranked.svg' },
|
||||
{ rank: 'Bronze 1', elo: 0, color: '#CD7F32', image: 'rank_bronze.svg' },
|
||||
{ rank: 'Bronze 2', elo: 200, color: '#CD7F32', image: 'rank_bronze.svg' },
|
||||
{ rank: 'Bronze 3', elo: 400, color: '#CD7F32', image: 'rank_bronze.svg' },
|
||||
{ rank: 'Silver 1', elo: 700, color: '#C0C0C0', image: 'rank_silver.svg' },
|
||||
{ rank: 'Silver 2', elo: 900, color: '#C0C0C0', image: 'rank_silver.svg' },
|
||||
{ rank: 'Silver 3', elo: 1100, color: '#C0C0C0', image: 'rank_silver.svg' },
|
||||
{ rank: 'Gold 1', elo: 1300, color: '#FFD700', image: 'rank_gold.svg' },
|
||||
{ rank: 'Gold 2', elo: 1600, color: '#FFD700', image: 'rank_gold.svg' },
|
||||
{ rank: 'Gold 3', elo: 2000, color: '#FFD700', image: 'rank_gold.svg' },
|
||||
{ rank: 'Platinum', elo: 2300, color: '#4B69FF', image: 'rank_platinum.svg' },
|
||||
{ rank: 'Diamond', elo: 3000, color: '#4B69FF', image: 'rank_diamond.svg' },
|
||||
{ rank: 'Master', elo: 3300, color: '#EE7032', image: 'rank_master.svg' },
|
||||
{ rank: 'Kracked', elo: 4700, color: '#FF0000', image: 'rank_kracked.svg' },
|
||||
];
|
||||
|
||||
const RANK_IMG_BASE = 'https://assets.krunker.io/img/ranked/ranks/';
|
||||
|
||||
function getRankData(currentElo: number): { current: RankInfo; next: RankInfo; progress: number; isMax: boolean } {
|
||||
let idx = 0;
|
||||
for (let i = RANKS.length - 1; i >= 0; i--) {
|
||||
if (RANKS[i].elo !== null && currentElo >= RANKS[i].elo!) { idx = i; break; }
|
||||
}
|
||||
const current = RANKS[idx];
|
||||
const next = RANKS[idx + 1] || current;
|
||||
const isMax = idx === RANKS.length - 1;
|
||||
let progress = 0;
|
||||
if (!isMax && current.elo !== null && next.elo !== null) {
|
||||
progress = Math.min(100, Math.max(0, ((currentElo - current.elo!) / (next.elo! - current.elo!)) * 100));
|
||||
} else if (isMax) {
|
||||
progress = 100;
|
||||
}
|
||||
return { current, next, progress, isMax };
|
||||
}
|
||||
|
||||
function openRankPopup(): void {
|
||||
if (document.getElementById('kpc-rank-overlay')) return;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'kpc-rank-overlay';
|
||||
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
let grid = '';
|
||||
for (const r of RANKS) {
|
||||
grid += `<div class="kpc-rank-grid-item">
|
||||
<img src="${RANK_IMG_BASE}${r.image}" loading="lazy">
|
||||
<div><div class="kpc-rank-name" style="color:${r.color}">${r.rank}</div>
|
||||
<div class="kpc-rank-elo">${r.elo !== null ? r.elo + '+' : 'Placement'}</div></div></div>`;
|
||||
}
|
||||
|
||||
overlay.innerHTML = `<div class="kpc-rank-popup">
|
||||
<div class="kpc-rank-popup-header"><h2>Rank Distribution</h2>
|
||||
<div class="kpc-rank-popup-close" id="kpc-rank-close">\u2715</div></div>
|
||||
<div class="kpc-rank-grid">${grid}</div></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('kpc-rank-close')?.addEventListener('click', () => overlay.remove());
|
||||
}
|
||||
|
||||
function injectRankBar(container: Element): void {
|
||||
if (container.querySelector('#kpc-elo-tracker')) return;
|
||||
const statValues = container.querySelectorAll('.quick-stat-value');
|
||||
if (!statValues.length) return;
|
||||
const currentElo = Number(statValues[0].textContent);
|
||||
if (isNaN(currentElo)) return;
|
||||
|
||||
const data = getRankData(currentElo);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'kpc-elo-tracker';
|
||||
|
||||
const nextHtml = data.isMax ? '' :
|
||||
`<div class="kpc-rank-container"><img src="${RANK_IMG_BASE}${data.next.image}" class="kpc-elo-rank-img"><span>${data.next.rank}</span></div>`;
|
||||
const barText = data.isMax ? `${currentElo}` : `${currentElo} / ${data.next.elo}`;
|
||||
|
||||
wrapper.innerHTML = `<div class="kpc-elo-info-row">
|
||||
<div class="kpc-rank-container"><img src="${RANK_IMG_BASE}${data.current.image}" class="kpc-elo-rank-img"><span>${data.current.rank}</span></div>
|
||||
<div class="kpc-elo-bar-bg"><div class="kpc-elo-bar-fill" style="width:${data.progress}%"></div>
|
||||
<div class="kpc-elo-bar-text">${barText}</div></div>${nextHtml}</div>`;
|
||||
|
||||
const statsBlock = container.querySelector('.quick-stats');
|
||||
if (statsBlock) container.insertBefore(wrapper, statsBlock);
|
||||
else container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
function injectRankButton(card: Element): void {
|
||||
if (card.querySelector('#kpc-rank-list-btn')) return;
|
||||
const btn = document.createElement('div');
|
||||
btn.id = 'kpc-rank-list-btn';
|
||||
btn.innerHTML = '<span class="material-icons" style="font-size:16px;vertical-align:middle;margin-right:4px;">list</span> Ranks';
|
||||
btn.addEventListener('click', openRankPopup);
|
||||
if (getComputedStyle(card as HTMLElement).position === 'static') (card as HTMLElement).style.position = 'relative';
|
||||
card.appendChild(btn);
|
||||
}
|
||||
|
||||
function checkRankedMenu(): void {
|
||||
const card = document.querySelector('.rank-card');
|
||||
const container = document.querySelector('.rank-and-stats');
|
||||
if (card && container) {
|
||||
injectRankBar(container);
|
||||
injectRankButton(card);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ranked Queue Button ──
|
||||
|
||||
function injectQueueButton(): void {
|
||||
const footer = document.querySelector('.footer-controls');
|
||||
if (!footer || footer.querySelector('#kpc-ranked-queue-btn')) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'kpc-ranked-queue-btn';
|
||||
btn.className = 'kpc-ranked-queue-btn';
|
||||
btn.innerHTML = '<span class="material-icons" style="font-size:20px;vertical-align:middle;">open_in_new</span>';
|
||||
btn.title = 'Open External Queue';
|
||||
btn.addEventListener('click', () => {
|
||||
let token = localStorage.getItem('__FRVR_auth_access_token') || '';
|
||||
token = token.replace(/"/g, '').replace(/\//g, '');
|
||||
const regionEl = document.querySelector('.region-indicator');
|
||||
let region = 'na';
|
||||
if (regionEl) {
|
||||
const text = regionEl.textContent || '';
|
||||
const parts = text.split(': ');
|
||||
const regionName = parts[1] || parts[0];
|
||||
if (regionName.includes('Europe')) region = 'eu';
|
||||
else if (regionName.includes('Asia')) region = 'as';
|
||||
}
|
||||
const allRegions = localStorage.getItem('s_rankedAllRegions') === 'true';
|
||||
ipcRenderer.send('open-ranked-queue', token, region, allRegions);
|
||||
});
|
||||
|
||||
const lastChild = footer.lastElementChild;
|
||||
if (lastChild) footer.insertBefore(btn, lastChild);
|
||||
else footer.appendChild(btn);
|
||||
}
|
||||
|
||||
export function initRankProgress(): void {
|
||||
// Poll for window.openRankedMenu — Krunker defines it async after DOM load
|
||||
let attempts = 0;
|
||||
const poll = setInterval(() => {
|
||||
const origRanked = (window as any).openRankedMenu;
|
||||
if (origRanked && !origRanked.__kpcRankPatched) {
|
||||
clearInterval(poll);
|
||||
|
||||
let rankObserver: MutationObserver | null = null;
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const patched = function (this: any, ...args: any[]) {
|
||||
origRanked.apply(this, args);
|
||||
|
||||
const modal = document.querySelector('.rankedMenuModal');
|
||||
if (!modal) return;
|
||||
|
||||
rankObserver = new MutationObserver(checkRankedMenu);
|
||||
rankObserver.observe(modal, { childList: true, subtree: true });
|
||||
checkRankedMenu();
|
||||
injectQueueButton();
|
||||
|
||||
cleanupInterval = setInterval(() => {
|
||||
if (!document.querySelector('.rankedMenuModal')) {
|
||||
if (rankObserver) { rankObserver.disconnect(); rankObserver = null; }
|
||||
if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; }
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
(patched as any).__kpcRankPatched = true;
|
||||
(window as any).openRankedMenu = patched;
|
||||
} else if (++attempts > 75) { // 15s timeout
|
||||
clearInterval(poll);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { UserscriptInstance } from './userscripts';
|
||||
import { initTranslator, updateTranslatorConfig } from './translator';
|
||||
import { setDeathAnimBlock, setCleanerMenu, setMenuTimer, escapeHtml } from './utils';
|
||||
import { initChat, setBetterChat, setChatHistorySize } from './chat';
|
||||
import { initHPCounter, destroyHPCounter } from './competitive';
|
||||
import { initHPCounter, destroyHPCounter, initRankProgress } from './competitive';
|
||||
import { checkChangelog } from './changelog';
|
||||
import type { Keybind } from '../main/config';
|
||||
|
||||
@@ -1675,10 +1675,13 @@ ipcRenderer.on('main_did-finish-load', () => {
|
||||
}, _console);
|
||||
}
|
||||
|
||||
// ── Hardpoint enemy counter ──
|
||||
// ── Competitive features ──
|
||||
if (isGamePage && (gameConf?.hpEnemyCounter ?? true)) {
|
||||
initHPCounter();
|
||||
}
|
||||
if (isGamePage) {
|
||||
initRankProgress();
|
||||
}
|
||||
|
||||
// ── CPU throttle state notifications ──
|
||||
if (isGamePage) {
|
||||
|
||||
Reference in New Issue
Block a user