A needle speedometer for your speedrun progress, right on your OBS scene. Every split kicks the needle: green grows the meter, gold snaps it up, red knocks it back. Finish on a new PB and the meter flies to 100 and sparks.
Everything runs over OBS WebSocket — no separate web server, and all the logic lives in a LiveSplit component.
demo · gold split
Everything to stream with, in one archive: the .dll component, the pbmetr-gauge.html layout, pbmetr-obs.ini settings and separate README_EN.txt / README_RU.txt install guides. Source files are below, individually.
Two steps: a browser source in OBS and the component in LiveSplit.
4455. Easiest to leave the password empty (localhost only).pbmetr-gauge.html. Width 520, height 360, transparent background.pbmetr-obs.ini. If OBS has a password, add ?pass=YOUR_PASSWORD to the URL.LiveSplit.PBMetrObs.dll and pbmetr-obs.ini into the Components folder next to LiveSplit.exe.neon / minimal / retro) and feel (growth, knockback, needle snap) live in pbmetr-obs.ini. The component re-reads it every ~2 seconds — tweak it mid-stream, nothing to restart. You can rebuild the .dll from source with build.bat — using the compiler built into Windows, no .NET SDK needed. Everything inside the archive — readable right here.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PB-metr</title>
<style>
html,body{margin:0;padding:0;width:100%;height:100%;background:transparent;overflow:hidden;
font-family:"Segoe UI",Roboto,Arial,sans-serif;user-select:none;}
#wrap{position:fixed;inset:0;}
canvas{position:absolute;inset:0;width:100%;height:100%;}
#scan{position:absolute;inset:0;pointer-events:none;opacity:0;mix-blend-mode:overlay;
background:repeating-linear-gradient(0deg, rgba(0,0,0,.35) 0px, rgba(0,0,0,.35) 2px, transparent 2px, transparent 4px);}
#panel{position:absolute;left:12px;bottom:12px;display:none;gap:6px;flex-wrap:wrap;
background:rgba(10,12,20,.72);padding:10px;border-radius:10px;backdrop-filter:blur(4px);
max-width:300px;z-index:10;}
#panel.show{display:flex;}
#panel button{cursor:pointer;border:none;border-radius:7px;padding:7px 10px;font-size:12px;
font-weight:600;color:#fff;background:#2a2f45;transition:.15s;}
#panel button:hover{filter:brightness(1.25);}
#panel .green{background:#16a34a;} #panel .gold{background:#caa12a;color:#1a1400;}
#panel .red{background:#dc2626;} #panel .pb{background:#7c3aed;}
#panel .reset{background:#475569;} #panel .auto{background:#0ea5e9;}
#panel .row{display:flex;gap:6px;width:100%;}
#panel select{flex:1;border-radius:7px;border:none;padding:6px;background:#2a2f45;color:#fff;font-size:12px;}
#status{position:absolute;right:10px;top:8px;font-size:11px;color:#7e8aa5;z-index:10;
opacity:.0;transition:opacity .3s;}
#status.show{opacity:.85;}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv"></canvas>
<div id="scan"></div>
<div id="status"></div>
<div id="panel">
<div class="row">
<select id="themeSel" title="Theme">
<option value="neon">Neon / racing</option>
<option value="synthwave">Synthwave</option>
<option value="aurora">Aurora</option>
<option value="carbon">Carbon (redline)</option>
<option value="minimal">Minimal</option>
<option value="retro">Retro CRT</option>
<option value="horror">Horror</option>
</select>
</div>
<button class="green" data-ev="green">Green −</button>
<button class="gold" data-ev="gold">Gold ★</button>
<button class="red" data-ev="red">Red +</button>
<button class="pb" data-ev="pb">Finish PB</button>
<button class="reset" data-ev="reset">Reset</button>
<button class="auto" data-ev="auto">Auto demo</button>
</div>
</div>
<script>
"use strict";
const Q = new URLSearchParams(location.search);
const DEMO = Q.has("demo");
const SHOWUI = DEMO || Q.has("ui");
const OBS_URL = "ws://" + (Q.get("host")||"127.0.0.1") + ":" + (Q.get("port")||"4455");
const OBS_PASS = Q.get("pass")||""; // пароль OBS WebSocket (если включён)
// Живой конфиг «ощущений». Значения по умолчанию; компонент LiveSplit шлёт
// событие {type:"config", ...} и обновляет их на лету (см. applyConfig).
const CFG = {
idle: 0,
green: 6,
gold: 12,
red: 8,
combo: 1.5,
kick: 1.0,
};
const THEMES = {
neon: {
face:"#0a0e1f", faceEdge:"#1c2c52",
track:"#161e38", tick:"#34406b", tickMajor:"#7d9be6",
z1:"#00e5ff", z2:"#7c4dff", z3:"#ff2d75",
needle:"#ff2d75", needleGlow:"#ff2d75", hub:"#0a0e1f", hubEdge:"#ff2d75",
text:"#eaf2ff", sub:"#8294b8", val:"#ffffff",
gold:"#ffd34d", goldGlow:"#ffbf2e", green:"#27e39b", red:"#ff5470",
glow:true, font:"'Segoe UI',Roboto,sans-serif",
},
synthwave: {
face:"#1a0b2e", faceEdge:"#3d1d63",
track:"#2a1450", tick:"#6a3aa0", tickMajor:"#ff6ad5",
z1:"#00d4ff", z2:"#c850ff", z3:"#ff5e8a",
needle:"#ff6ad5", needleGlow:"#ff6ad5", hub:"#1a0b2e", hubEdge:"#ffcf3a",
text:"#ffd1f5", sub:"#b07bff", val:"#fff0fb",
gold:"#ffd34d", goldGlow:"#ffbf2e", green:"#5effc4", red:"#ff5e8a",
glow:true, scan:true, font:"'Segoe UI',Roboto,sans-serif",
},
aurora: {
face:"#04130f", faceEdge:"#0c3b30",
track:"#0a2a22", tick:"#1f6b54", tickMajor:"#48e0a0",
z1:"#1fffc4", z2:"#34d0ff", z3:"#9b6bff",
needle:"#7cffd0", needleGlow:"#3affc0", hub:"#04130f", hubEdge:"#7cffd0",
text:"#dffff4", sub:"#5fae93", val:"#ffffff",
gold:"#ffe27a", goldGlow:"#ffd24d", green:"#2bffb0", red:"#ff7a8a",
glow:true, font:"'Segoe UI',Roboto,sans-serif",
},
carbon: {
face:"#101113", faceEdge:"#26292e",
track:"#1c1e22", tick:"#3a3f47", tickMajor:"#aeb6c2",
z1:"#d8dee8", z2:"#ffb02e", z3:"#ff3b30",
needle:"#ff3b30", needleGlow:"#ff5a3c", hub:"#0c0d0f", hubEdge:"#ff3b30",
text:"#eef1f6", sub:"#8a93a3", val:"#ffffff",
gold:"#ffc234", goldGlow:"#ffb02e", green:"#46d39a", red:"#ff3b30",
glow:true, font:"'Segoe UI',Roboto,sans-serif",
},
minimal: {
face:"#14161d", faceEdge:"#252934",
track:"#262a36", tick:"#3f4655", tickMajor:"#9aa3b5",
z1:"#6ee7d2", z2:"#fbbf5a", z3:"#f97a82",
needle:"#f4f7fb", needleGlow:"#f4f7fb", hub:"#14161d", hubEdge:"#9aa3b5",
text:"#f4f7fb", sub:"#9aa3b5", val:"#ffffff",
gold:"#fcd34d", goldGlow:"#fcd34d", green:"#34d399", red:"#f87171",
glow:false, font:"'Segoe UI',Roboto,sans-serif",
},
retro: {
face:"#0c1a0c", faceEdge:"#1d3a1d",
track:"#13260f", tick:"#2f5a2a", tickMajor:"#7CFC00",
z1:"#39ff14", z2:"#e6ff00", z3:"#ff8c00",
needle:"#e6ff00", needleGlow:"#caff00", hub:"#0c1a0c", hubEdge:"#39ff14",
text:"#7CFC00", sub:"#3ea33e", val:"#d6ff8a",
gold:"#ffe600", goldGlow:"#ffd000", green:"#39ff14", red:"#ff5e3a",
glow:true, scan:true, font:"'Courier New',monospace",
},
horror: {
face:"#0a0405", faceEdge:"#280c0e",
track:"#180708", tick:"#3e1618", tickMajor:"#9a2b2b",
z1:"#4a0d16", z2:"#8c0f14", z3:"#ff1414",
needle:"#ff1717", needleGlow:"#ff0000", hub:"#0a0405", hubEdge:"#b31217",
text:"#c8b3ab", sub:"#6e4a4a", val:"#ece0da",
gold:"#d9b44a", goldGlow:"#b8862e", green:"#6bbf59", red:"#ff2b2b",
glow:true, font:"'Segoe UI',Roboto,sans-serif",
},
};
let THEME = THEMES[Q.get("theme")] && Q.get("theme") || "neon";
let T = THEMES[THEME];
const M = {
value: CFG.idle,
vel: 0,
target: CFG.idle,
gold: 0,
pb: false,
flash: 0,
flashColor: "#39e08a",
shake: 0,
trem: 0,
wander: 0,
splitName: "", // подпись текущего сплита
combo: 0, // число зелёных подряд
lastBeat: 0, // для пульса
};
const sparks = []; // частицы-искры
const streaks = []; // линии скорости
// ---------- canvas ----------
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let W=0,H=0,DPR=1;
function resize(){
DPR = Math.min(window.devicePixelRatio||1, 2);
W = window.innerWidth; H = window.innerHeight;
cv.width = W*DPR; cv.height = H*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
}
window.addEventListener("resize", resize); resize();
const A0 = Math.PI;
const A1 = Math.PI*2;
const SWEEP = A1-A0;
const RUN_CAP = 90;
function angleFor(v){ return A0 + Math.max(0,Math.min(100,v))/100*SWEEP; }
function geom(){
const R = Math.min(W*0.45, H*0.54);
const cx = W/2;
const cy = R*1.30 + 12;
return {cx:cx, cy:cy, R:R};
}
function clamp(v,a,b){return Math.max(a,Math.min(b,v));}
function setTarget(v){ M.target = clamp(v,0,100); }
function impulse(power){ M.vel += power; M.shake = Math.min(1, M.shake + Math.abs(power)/120); }
function emitStreak(){
streaks.push({a: angleFor(M.value)+(Math.random()-.5)*0.25, r:0, life:1, spd:6+Math.random()*4});
}
function emitSparks(n, color, burst){
const _g=geom(), cx=_g.cx, cy=_g.cy, R=_g.R;
const a=angleFor(M.value);
for(let i=0;i<n;i++){
const ang = burst ? Math.random()*Math.PI*2 : a + (Math.random()-.5)*0.6;
const sp = (burst?2:1)*(1.5+Math.random()*3.5);
sparks.push({
x: cx+Math.cos(a)*R*(burst?Math.random():1),
y: cy+Math.sin(a)*R*(burst?Math.random():1),
vx: Math.cos(ang)*sp, vy: Math.sin(ang)*sp - (burst?1.5:0),
life:1, decay:0.012+Math.random()*0.02,
color, size:1+Math.random()*2.5,
});
}
}
function applyConfig(ev){
if(ev.theme) applyTheme(ev.theme);
if(ev.idle!=null) CFG.idle = +ev.idle;
if(ev.green!=null) CFG.green = +ev.green;
if(ev.gold!=null) CFG.gold = +ev.gold;
if(ev.red!=null) CFG.red = +ev.red;
if(ev.combo!=null) CFG.combo = +ev.combo;
if(ev.kick!=null) CFG.kick = +ev.kick;
if(M.combo===0 && !M.pb) setTarget(CFG.idle);
}
function applyEvent(ev){
if(ev.type==="config"){ applyConfig(ev); return; }
const mag = Math.abs(ev.magnitude||0);
const K = CFG.kick;
switch(ev.type){
case "green": {
M.combo++; M.trem=0;
const headG = Math.max(0, RUN_CAP - M.target);
const gG = (0.09 + Math.min(mag*0.03,0.10) + Math.min(M.combo*0.004,0.05)) * (CFG.green/6);
setTarget(M.target + headG*Math.min(gG,0.5));
impulse((26 + Math.min(mag*12,44))*K);
M.flash=1; M.flashColor=T.green;
for(let i=0;i<4;i++) emitStreak();
emitSparks(10, T.green, false);
break;
}
case "gold": {
M.combo++;
M.gold = 1; M.trem=1;
const headG = Math.max(0, RUN_CAP - M.target);
const gG = (0.20 + Math.min(mag*0.04,0.14)) * (CFG.gold/12);
setTarget(M.target + headG*Math.min(gG,0.6));
impulse((48 + Math.min(mag*14,50))*K);
M.flash=1; M.flashColor=T.gold;
for(let i=0;i<7;i++) emitStreak();
emitSparks(26, T.gold, false);
break;
}
case "red": {
M.combo=0; M.trem=0;
const drop = CFG.red + M.target*0.12 + Math.min(mag*3, 12);
setTarget(M.target - drop);
impulse(-(34 + Math.min(mag*10,40))*K);
M.flash=1; M.flashColor=T.red;
emitSparks(8, T.red, false);
break;
}
case "pb": {
M.combo++;
M.pb = true; M.gold = Math.max(M.gold, .6); M.trem=1;
setTarget(100);
impulse(64*K);
M.flash=1; M.flashColor=T.gold;
emitSparks(70, T.gold, true);
break;
}
case "finish": {
M.trem=0; impulse(10);
break;
}
case "reset": {
M.combo=0; M.pb=false; M.gold=0; M.trem=0; M.target=CFG.idle; M.vel=0; M.flash=0;
sparks.length=0; streaks.length=0;
break;
}
case "split": {
if(ev.isPB){ return applyEvent({type:"pb"}); }
const col = ev.color;
const m = Math.abs(col==="gold" ? (ev.segmentDeltaSec||0) : (ev.runDeltaSec||0));
if(col==="gold"){ M.combo++; M.gold=1; M.trem=1; M.flash=1; M.flashColor=T.gold; impulse((48+Math.min(m*14,50))*CFG.kick); for(let i=0;i<7;i++)emitStreak(); emitSparks(26,T.gold,false); }
else if(col==="red"){ M.combo=0; M.trem=0; M.flash=1; M.flashColor=T.red; impulse(-(34+Math.min(m*10,40))*CFG.kick); emitSparks(8,T.red,false); }
else { M.combo++; M.trem=0; M.flash=1; M.flashColor=T.green; impulse((26+Math.min(m*12,44))*CFG.kick); for(let i=0;i<4;i++)emitStreak(); emitSparks(10,T.green,false); }
if(ev.totalSplits>0 && ev.splitIndex!=null){
const p = Math.min(1, (ev.splitIndex+1)/ev.totalSplits);
let base = p*RUN_CAP;
if(col==="gold") base += 8;
else if(col==="green") base += 3 + Math.min(m*2,6);
else base -= 9 + Math.min(m*2,8);
setTarget(Math.max(0, Math.min(RUN_CAP, base)));
} else {
if(col==="gold"){ const h=Math.max(0,RUN_CAP-M.target); setTarget(M.target+h*Math.min((0.20+Math.min(m*0.04,0.14))*(CFG.gold/12),0.6)); }
else if(col==="red"){ setTarget(M.target-(CFG.red+M.target*0.12+Math.min(m*3,12))); }
else { const h=Math.max(0,RUN_CAP-M.target); setTarget(M.target+h*Math.min((0.09+Math.min(m*0.03,0.10)+Math.min(M.combo*0.004,0.05))*(CFG.green/6),0.5)); }
}
break;
}
}
if(ev.splitName!=null) M.splitName = ev.splitName;
setStatus("event: "+(ev.type||ev.color||"?"));
}
let last = performance.now();
function physics(dt){
const stiff = 90, damp = 9;
const a = (M.target - M.value)*stiff - M.vel*damp;
M.vel += a*dt;
M.value += M.vel*dt;
if(M.value<0){M.value=0; M.vel*=-0.3;}
if(M.value>100){M.value=100; M.vel*=-0.3;}
M.flash = Math.max(0, M.flash - dt*2.2);
M.shake = Math.max(0, M.shake - dt*2.5);
M.gold = Math.max(M.pb?0.6:0, M.gold - dt*0.12);
M.wander = M.wander*0.9 + (Math.random()-0.5)*1.0;
for(let i=sparks.length-1;i>=0;i--){
const s=sparks[i];
s.x+=s.vx; s.y+=s.vy; s.vy+=0.06; s.vx*=0.99; s.life-=s.decay;
if(s.life<=0) sparks.splice(i,1);
}
if(M.pb && sparks.length<120 && Math.random()<0.6) emitSparks(3, T.gold, true);
for(let i=streaks.length-1;i>=0;i--){
const k=streaks[i]; k.r+=k.spd; k.life-=dt*1.6;
if(k.life<=0) streaks.splice(i,1);
}
if(Math.abs(M.vel)>18 && Math.random()<0.4) emitStreak();
}
function lerpColor(c1,c2,t){
const a=hex(c1),b=hex(c2);
return `rgb(${Math.round(a[0]+(b[0]-a[0])*t)},${Math.round(a[1]+(b[1]-a[1])*t)},${Math.round(a[2]+(b[2]-a[2])*t)})`;
}
function hex(c){
if(c[0]==='#'){const n=parseInt(c.slice(1),16);return c.length>=7?[(n>>16)&255,(n>>8)&255,n&255]:[(n>>8)*17&255,((n>>4)&15)*17,(n&15)*17];}
const m=c.match(/\d+/g); return m?[+m[0],+m[1],+m[2]]:[255,255,255];
}
function roundRect(ctx,x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function draw(){
ctx.clearRect(0,0,W,H);
const _g=geom(), cx=_g.cx, cy=_g.cy, R=_g.R;
const goldT=M.gold;
const sh = M.shake*6 + (M.trem?R*0.012:0) + (M.pb?R*0.02:0);
ctx.save();
ctx.translate((Math.random()-.5)*sh, (Math.random()-.5)*sh);
ctx.save();
ctx.beginPath(); ctx.arc(cx,cy,R*1.16,A0,A1); ctx.closePath();
const fg = ctx.createLinearGradient(0,cy-R,0,cy);
fg.addColorStop(0, T.faceEdge); fg.addColorStop(1, T.face);
ctx.fillStyle=fg; ctx.globalAlpha=0.94; ctx.fill(); ctx.globalAlpha=1;
ctx.lineWidth=Math.max(2,R*0.02);
ctx.strokeStyle = goldT>0?lerpColor(T.faceEdge,T.gold,goldT):T.faceEdge; ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx-R*1.16,cy); ctx.lineTo(cx+R*1.16,cy);
ctx.lineWidth=Math.max(2,R*0.03);
ctx.strokeStyle = goldT>0?lerpColor(T.faceEdge,T.gold,goldT):T.tickMajor;
if(T.glow){ctx.shadowBlur=8; ctx.shadowColor=ctx.strokeStyle;}
ctx.stroke(); ctx.shadowBlur=0;
ctx.restore();
const trackW = R*0.13;
ctx.lineCap="round";
ctx.beginPath(); ctx.arc(cx,cy,R,A0,A1); ctx.lineWidth=trackW; ctx.strokeStyle=T.track; ctx.stroke();
const steps=64; const cur=angleFor(M.value);
for(let i=0;i<steps;i++){
const f0=i/steps, f1=(i+1)/steps;
const aa=A0+f0*SWEEP, bb=A0+f1*SWEEP;
if(aa>cur) break;
const seg = Math.min(bb,cur);
let col;
if(f0<0.5) col=lerpColor(T.z1,T.z2,f0/0.5);
else col=lerpColor(T.z2,T.z3,(f0-0.5)/0.5);
if(goldT>0) col=lerpColor(col,T.gold,goldT*0.85);
ctx.beginPath(); ctx.arc(cx,cy,R,aa,seg);
ctx.lineWidth=trackW; ctx.strokeStyle=col;
if(T.glow){ctx.shadowBlur=14*((f0>0.66?1:0.5)+goldT); ctx.shadowColor=col;}
ctx.stroke(); ctx.shadowBlur=0;
}
const majors=11;
for(let i=0;i<majors;i++){
const v=i*10, ang=angleFor(v);
const x1=cx+Math.cos(ang)*(R-trackW*0.6), y1=cy+Math.sin(ang)*(R-trackW*0.6);
const x2=cx+Math.cos(ang)*(R-trackW*1.5), y2=cy+Math.sin(ang)*(R-trackW*1.5);
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2);
ctx.lineWidth=Math.max(1.5,R*0.012); ctx.strokeStyle=goldT>0?lerpColor(T.tickMajor,T.gold,goldT):T.tickMajor; ctx.stroke();
}
for(let i=0;i<=50;i++){
if(i%5===0) continue;
const ang=angleFor(i*2);
const x1=cx+Math.cos(ang)*(R-trackW*0.7), y1=cy+Math.sin(ang)*(R-trackW*0.7);
const x2=cx+Math.cos(ang)*(R-trackW*1.15), y2=cy+Math.sin(ang)*(R-trackW*1.15);
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2);
ctx.lineWidth=1; ctx.strokeStyle=T.tick; ctx.stroke();
}
for(const k of streaks){
const x=cx+Math.cos(k.a)*(R*0.5+k.r), y=cy+Math.sin(k.a)*(R*0.5+k.r);
const x2=cx+Math.cos(k.a)*(R*0.5+k.r+14), y2=cy+Math.sin(k.a)*(R*0.5+k.r+14);
ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x2,y2);
ctx.strokeStyle = goldT>0?T.gold:(M.flashColor); ctx.globalAlpha=k.life*0.5; ctx.lineWidth=2; ctx.stroke();
ctx.globalAlpha=1;
}
const jitter = (Math.abs(M.vel)*0.0006 + M.value*0.00012 + ((M.trem||M.pb)?0.015:0))*(Math.random()-.5);
const na = angleFor(M.value + M.wander) + jitter;
const nlen=R*0.9;
const nx=cx+Math.cos(na)*nlen, ny=cy+Math.sin(na)*nlen;
const tx=cx, ty=cy;
const ncol = goldT>0?lerpColor(T.needle,T.gold,goldT):T.needle;
const px=Math.cos(na+Math.PI/2), py=Math.sin(na+Math.PI/2);
const baseW=R*0.035;
ctx.beginPath();
ctx.moveTo(nx,ny);
ctx.lineTo(cx+px*baseW, cy+py*baseW);
ctx.lineTo(tx,ty);
ctx.lineTo(cx-px*baseW, cy-py*baseW);
ctx.closePath();
ctx.fillStyle=ncol;
if(T.glow){ctx.shadowBlur=18+goldT*22; ctx.shadowColor=goldT>0?T.goldGlow:T.needleGlow;}
ctx.fill(); ctx.shadowBlur=0;
ctx.beginPath(); ctx.arc(cx,cy,R*0.07,0,Math.PI*2);
ctx.fillStyle=T.hub; ctx.fill();
ctx.lineWidth=Math.max(2,R*0.018); ctx.strokeStyle=goldT>0?T.gold:T.hubEdge; ctx.stroke();
ctx.beginPath(); ctx.arc(cx,cy,R*0.022,0,Math.PI*2); ctx.fillStyle=ncol; ctx.fill();
for(const s of sparks){
ctx.globalAlpha=Math.max(0,s.life);
ctx.beginPath(); ctx.arc(s.x,s.y,s.size,0,Math.PI*2);
ctx.fillStyle=s.color;
if(T.glow){ctx.shadowBlur=8; ctx.shadowColor=s.color;}
ctx.fill(); ctx.shadowBlur=0;
}
ctx.globalAlpha=1;
ctx.textAlign="center"; ctx.textBaseline="middle";
const winW=R*0.66, winH=R*0.34, winCy=cy-R*0.30;
ctx.save();
roundRect(ctx, cx-winW/2, winCy-winH/2, winW, winH, R*0.05);
const wg=ctx.createLinearGradient(0,winCy-winH/2,0,winCy+winH/2);
wg.addColorStop(0,"rgba(0,0,0,0.5)"); wg.addColorStop(1,"rgba(0,0,0,0.78)");
ctx.fillStyle=wg; ctx.fill();
ctx.lineWidth=Math.max(1.5,R*0.012);
ctx.strokeStyle=goldT>0?T.gold:T.tickMajor;
if(T.glow){ctx.shadowBlur=8; ctx.shadowColor=ctx.strokeStyle;}
ctx.stroke(); ctx.shadowBlur=0;
ctx.restore();
ctx.font=`700 ${Math.round(R*0.072)}px ${T.font}`;
ctx.fillStyle=goldT>0?T.gold:T.sub;
ctx.fillText(M.pb? "PB! ★" : "PB METER", cx, winCy-winH*0.26);
let valCol = goldT>0?T.gold:(M.flash>0?lerpColor(T.val,M.flashColor,M.flash):T.val);
ctx.font=`800 ${Math.round(R*0.20)}px ${T.font}`;
ctx.fillStyle=valCol;
if(T.glow){ctx.shadowBlur=12+goldT*16; ctx.shadowColor=valCol;}
ctx.fillText(Math.round(M.value), cx, winCy+winH*0.16);
ctx.shadowBlur=0;
let sub = M.splitName||"";
if(M.combo>=2 && !M.pb) sub = (sub?sub+" ":"") + "×"+M.combo;
if(sub){ ctx.font=`600 ${Math.round(R*0.07)}px ${T.font}`; ctx.fillStyle=T.sub; ctx.fillText(sub, cx, cy+R*0.16); }
ctx.textBaseline="alphabetic";
ctx.restore();
}
function loop(now){
let dt=(now-last)/1000; last=now; if(dt>0.05) dt=0.05;
physics(dt); draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
let statusTimer=null;
function setStatus(t){
if(!SHOWUI) return;
const el=document.getElementById("status");
el.textContent=t; el.classList.add("show");
clearTimeout(statusTimer); statusTimer=setTimeout(()=>el.classList.remove("show"),2500);
}
function applyTheme(name){
if(!THEMES[name]) return;
THEME=name; T=THEMES[name];
document.getElementById("scan").style.opacity = T.scan?"0.5":"0";
}
applyTheme(THEME);
function obsConnect(){
let ws;
try{ ws=new WebSocket(OBS_URL); }catch(e){ setStatus("OBS WS: ошибка"); return retry(); }
ws.onopen=()=>setStatus("OBS WS: подключение…");
ws.onclose=()=>{ setStatus("OBS WS: отключено"); retry(); };
ws.onerror=()=>{};
ws.onmessage=async(msg)=>{
let d; try{ d=JSON.parse(msg.data); }catch(e){ return; }
if(d.op===0){
const id={op:1, d:{rpcVersion:1, eventSubscriptions:1}};
if(d.d.authentication){
const a=d.d.authentication;
const secret = b64(sha256Bytes(strBytes(OBS_PASS + a.salt)));
const auth = b64(sha256Bytes(strBytes(secret + a.challenge)));
id.d.authentication=auth;
}
ws.send(JSON.stringify(id));
}
else if(d.op===2){ setStatus("OBS WS: готово ✓"); }
else if(d.op===5){
if(d.d.eventType==="CustomEvent"){
const data=d.d.eventData||{};
if(data.realm==="pbmetr" && data.payload) applyEvent(data.payload);
else if(data.realm==="pbmetr") applyEvent(data);
}
}
};
}
let retryTimer=null;
function retry(){ clearTimeout(retryTimer); retryTimer=setTimeout(obsConnect, 3000); }
if(!DEMO) obsConnect();
function strBytes(s){ const u=unescape(encodeURIComponent(s)); const a=new Uint8Array(u.length); for(let i=0;i<u.length;i++)a[i]=u.charCodeAt(i); return a; }
function b64(bytes){ let s=""; for(let i=0;i<bytes.length;i++)s+=String.fromCharCode(bytes[i]); return btoa(s); }
function sha256Bytes(data){
const K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
let h=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
const l=data.length; const withOne=l+1; const k=(56-withOne%64+64)%64;
const total=withOne+k+8; const buf=new Uint8Array(total);
buf.set(data,0); buf[l]=0x80;
const bitLen=l*8;
const dv=new DataView(buf.buffer);
dv.setUint32(total-4, bitLen>>>0, false);
dv.setUint32(total-8, Math.floor(bitLen/0x100000000), false);
const rr=(x,n)=>(x>>>n)|(x<<(32-n));
const w=new Uint32Array(64);
for(let off=0;off<total;off+=64){
for(let i=0;i<16;i++) w[i]=dv.getUint32(off+i*4,false);
for(let i=16;i<64;i++){
const s0=rr(w[i-15],7)^rr(w[i-15],18)^(w[i-15]>>>3);
const s1=rr(w[i-2],17)^rr(w[i-2],19)^(w[i-2]>>>10);
w[i]=(w[i-16]+s0+w[i-7]+s1)|0;
}
let [a,b,c,d2,e,f,g,hh]=h;
for(let i=0;i<64;i++){
const S1=rr(e,6)^rr(e,11)^rr(e,25);
const ch=(e&f)^(~e&g);
const t1=(hh+S1+ch+K[i]+w[i])|0;
const S0=rr(a,2)^rr(a,13)^rr(a,22);
const maj=(a&b)^(a&c)^(b&c);
const t2=(S0+maj)|0;
hh=g;g=f;f=e;e=(d2+t1)|0;d2=c;c=b;b=a;a=(t1+t2)|0;
}
h[0]=(h[0]+a)|0;h[1]=(h[1]+b)|0;h[2]=(h[2]+c)|0;h[3]=(h[3]+d2)|0;
h[4]=(h[4]+e)|0;h[5]=(h[5]+f)|0;h[6]=(h[6]+g)|0;h[7]=(h[7]+hh)|0;
}
const out=new Uint8Array(32); const odv=new DataView(out.buffer);
for(let i=0;i<8;i++) odv.setUint32(i*4,h[i]>>>0,false);
return out;
}
// ============================================================
// Демо / панель управления
// ============================================================
if(SHOWUI){
document.getElementById("panel").classList.add("show");
document.getElementById("themeSel").value=THEME;
document.getElementById("themeSel").addEventListener("change",e=>applyTheme(e.target.value));
document.getElementById("panel").addEventListener("click",e=>{
const b=e.target.closest("button"); if(!b) return;
const ev=b.dataset.ev;
if(ev==="auto"){ toggleAuto(); return; }
if(ev==="green") applyEvent({type:"green", magnitude:1.2, splitName:"Green split"});
else if(ev==="gold") applyEvent({type:"gold", magnitude:2.0, splitName:"Gold split!"});
else if(ev==="red") applyEvent({type:"red", magnitude:1.5, splitName:"Red split"});
else if(ev==="pb") applyEvent({type:"pb", splitName:"New record"});
else if(ev==="reset") applyEvent({type:"reset"});
});
}
window.addEventListener("keydown",e=>{
const map={q:"green",w:"gold",e:"red",r:"pb",t:"reset"};
if(map[e.key]) applyEvent({type:map[e.key], magnitude:1.5});
});
let autoTimer=null, autoStep=0;
const SCRIPT=[
{type:"green",magnitude:0.8,splitName:"Split 1"},
{type:"green",magnitude:1.4,splitName:"Split 2"},
{type:"gold", magnitude:2.1,splitName:"Split 3 — GOLD!"},
{type:"green",magnitude:1.0,splitName:"Split 4"},
{type:"red", magnitude:1.8,splitName:"Split 5 — lost time"},
{type:"green",magnitude:0.6,splitName:"Split 6"},
{type:"gold", magnitude:1.5,splitName:"Split 7 — GOLD!"},
{type:"green",magnitude:2.2,splitName:"Split 8"},
{type:"pb", splitName:"FINISH — PB!"},
{type:"reset"},
];
function toggleAuto(){
if(autoTimer){ clearInterval(autoTimer); autoTimer=null; setStatus("авто-демо: стоп"); return; }
autoStep=0; applyEvent({type:"reset"});
setStatus("авто-демо: старт");
autoTimer=setInterval(()=>{
applyEvent(SCRIPT[autoStep%SCRIPT.length]);
autoStep++;
}, 1700);
}
if(DEMO) setTimeout(toggleAuto, 600);
</script>
</body>
</html>
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows.Forms;
using System.Xml;
using LiveSplit.Model;
using LiveSplit.UI;
using LiveSplit.UI.Components;
[assembly: ComponentFactory(typeof(LiveSplit.PBMetrObs.Factory))]
namespace LiveSplit.PBMetrObs
{
internal static class Log
{
private static readonly object _gate = new object();
private static string _path;
private static bool _banner;
static Log()
{
try
{
string docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if (!string.IsNullOrEmpty(docs)) _path = System.IO.Path.Combine(docs, "PB-metr", "pbmetr.log");
}
catch { _path = null; }
}
public static void W(string cat, string msg)
{
if (string.IsNullOrEmpty(_path)) return;
try
{
lock (_gate)
{
string dir = System.IO.Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
using (var sw = new StreamWriter(_path, true, Encoding.UTF8))
{
if (!_banner) { sw.WriteLine(); sw.WriteLine("=== PB-metr OBS session " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " ==="); _banner = true; }
sw.WriteLine("[" + DateTime.Now.ToString("HH:mm:ss.fff") + "] [" + (cat ?? "INFO") + "] " + (msg ?? ""));
}
}
}
catch { }
}
}
public class Factory : IComponentFactory
{
public string ComponentName { get { return "PB-metr OBS"; } }
public string Description { get { return "Отправляет цвет сплитов в OBS для спидометра PB-metr."; } }
public ComponentCategory Category { get { return ComponentCategory.Other; } }
public IComponent Create(LiveSplitState state) { return new PBMetrComponent(state); }
public string UpdateName { get { return ComponentName; } }
public string XMLURL { get { return ""; } }
public string UpdateURL { get { return ""; } }
public Version Version { get { return Version.Parse("1.0.0"); } }
}
public class IniConfig
{
public string Host = "127.0.0.1";
public int Port = 4455;
public string Password = "";
public string Comparison = "auto";
public string Theme = "neon";
public double Idle = 16, Green = 6, Gold = 12, Red = 8, Combo = 1.5, Kick = 1.0;
public static IniConfig Load()
{
IniConfig cfg = new IniConfig();
try
{
string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
string path = Path.Combine(dir, "pbmetr-obs.ini");
if (!File.Exists(path)) return cfg;
CultureInfo ci = CultureInfo.InvariantCulture;
string[] lines = File.ReadAllLines(path);
for (int li = 0; li < lines.Length; li++)
{
string line = lines[li].Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
int i = line.IndexOf('=');
if (i < 0) continue;
string key = line.Substring(0, i).Trim().ToLowerInvariant();
string val = line.Substring(i + 1).Trim();
int hash = val.IndexOf('#');
if (hash >= 0) val = val.Substring(0, hash).Trim();
if (key == "host") cfg.Host = val;
else if (key == "port") int.TryParse(val, out cfg.Port);
else if (key == "password") cfg.Password = val;
else if (key == "comparison") cfg.Comparison = val.Length == 0 ? "auto" : val;
else if (key == "theme") { if (val.Length > 0) cfg.Theme = val.ToLowerInvariant(); }
else if (key == "idle") double.TryParse(val, NumberStyles.Any, ci, out cfg.Idle);
else if (key == "green") double.TryParse(val, NumberStyles.Any, ci, out cfg.Green);
else if (key == "gold") double.TryParse(val, NumberStyles.Any, ci, out cfg.Gold);
else if (key == "red") double.TryParse(val, NumberStyles.Any, ci, out cfg.Red);
else if (key == "combo") double.TryParse(val, NumberStyles.Any, ci, out cfg.Combo);
else if (key == "kick") double.TryParse(val, NumberStyles.Any, ci, out cfg.Kick);
}
}
catch { }
return cfg;
}
}
public class ObsClient : IDisposable
{
private readonly string _host;
private readonly int _port;
private readonly string _password;
private readonly ConcurrentQueue<string> _outbox = new ConcurrentQueue<string>();
private readonly Random _rng = new Random();
private volatile bool _stop;
private volatile bool _identified;
private Thread _worker;
public Action OnIdentified;
public ObsClient(string host, int port, string password)
{
_host = host; _port = port; _password = password == null ? "" : password;
_worker = new Thread(new ThreadStart(Run));
_worker.IsBackground = true;
_worker.Start();
}
public void Broadcast(string payloadJson)
{
string msg = "{\"op\":6,\"d\":{\"requestType\":\"BroadcastCustomEvent\",\"requestId\":\"pb\",\"requestData\":{\"eventData\":{\"realm\":\"pbmetr\",\"payload\":" + payloadJson + "}}}}";
_outbox.Enqueue(msg);
string junk;
while (_outbox.Count > 50) _outbox.TryDequeue(out junk);
}
private static string Sha256B64(string s)
{
using (SHA256 sha = SHA256.Create())
return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(s)));
}
private void Run()
{
while (!_stop)
{
TcpClient tcp = null;
try
{
tcp = new TcpClient();
Log.W("OBS", "connecting " + _host + ":" + _port);
tcp.Connect(_host, _port);
NetworkStream stream = tcp.GetStream();
string key = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
string req = "GET / HTTP/1.1\r\nHost: " + _host + ":" + _port +
"\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: " + key +
"\r\nSec-WebSocket-Version: 13\r\n\r\n";
byte[] rb = Encoding.ASCII.GetBytes(req);
stream.Write(rb, 0, rb.Length); stream.Flush();
StringBuilder hsb = new StringBuilder();
int ch;
while ((ch = stream.ReadByte()) >= 0)
{
hsb.Append((char)ch);
if (hsb.Length >= 4 && hsb.ToString(hsb.Length - 4, 4) == "\r\n\r\n") break;
}
if (hsb.ToString().IndexOf(" 101") < 0) throw new Exception("no 101");
Log.W("OBS", "ws handshake 101 ok");
string hello = RecvMsg(stream);
if (hello != null) Log.W("OBS", "hello: " + (hello.Length > 220 ? hello.Substring(0, 220) : hello));
Match ms = Regex.Match(hello == null ? "" : hello, "\"salt\"\\s*:\\s*\"([^\"]*)\"");
Match mc = Regex.Match(hello == null ? "" : hello, "\"challenge\"\\s*:\\s*\"([^\"]*)\"");
string identify;
if (ms.Success && mc.Success)
{
string secret = Sha256B64(_password + ms.Groups[1].Value);
string auth = Sha256B64(secret + mc.Groups[1].Value);
identify = "{\"op\":1,\"d\":{\"rpcVersion\":1,\"eventSubscriptions\":0,\"authentication\":\"" + auth + "\"}}";
Log.W("OBS", "auth REQUIRED; identify WITH auth; password=" + (string.IsNullOrEmpty(_password) ? "EMPTY" : ("set(len=" + _password.Length + ")")));
}
else
{
identify = "{\"op\":1,\"d\":{\"rpcVersion\":1,\"eventSubscriptions\":0}}";
Log.W("OBS", "no auth required; identify WITHOUT auth");
}
SendFrame(stream, identify);
while (!_stop)
{
string mm = RecvMsg(stream);
if (mm == null) throw new Exception("closed after identify (Identified not received) - likely auth rejected");
Match om = Regex.Match(mm, "\"op\"\\s*:\\s*(\\d+)");
Log.W("OBS", "recv op=" + (om.Success ? om.Groups[1].Value : "?") + " : " + (mm.Length > 180 ? mm.Substring(0, 180) : mm));
if (om.Success && om.Groups[1].Value == "2") break;
}
_identified = true;
Log.W("OBS", "identified (ready to send)");
Action h = OnIdentified;
if (h != null) { try { h(); } catch { } }
while (!_stop)
{
string msg;
while (_outbox.TryDequeue(out msg)) SendFrame(stream, msg);
if (stream.DataAvailable)
{
string im = RecvMsg(stream);
if (im == null) throw new Exception("closed");
}
Thread.Sleep(40);
}
}
catch (Exception ex) { Log.W("OBS", "connection error: " + ex.Message); }
_identified = false;
try { if (tcp != null) tcp.Close(); }
catch { }
if (!_stop) Thread.Sleep(2000);
}
}
private byte[] ReadBytes(Stream stream, int n)
{
byte[] b = new byte[n]; int off = 0;
while (off < n) { int r = stream.Read(b, off, n - off); if (r <= 0) return null; off += r; }
return b;
}
private void SendFrame(Stream stream, string text)
{
byte[] p = Encoding.UTF8.GetBytes(text);
int len = p.Length;
MemoryStream ms = new MemoryStream();
ms.WriteByte(0x81);
if (len < 126) ms.WriteByte((byte)(0x80 | len));
else if (len < 65536) { ms.WriteByte((byte)(0x80 | 126)); ms.WriteByte((byte)((len >> 8) & 0xff)); ms.WriteByte((byte)(len & 0xff)); }
else { ms.WriteByte((byte)(0x80 | 127)); for (int i = 7; i >= 0; i--) ms.WriteByte((byte)((len >> (8 * i)) & 0xff)); }
byte[] mask = new byte[4]; _rng.NextBytes(mask); ms.Write(mask, 0, 4);
byte[] masked = new byte[len];
for (int i = 0; i < len; i++) masked[i] = (byte)(p[i] ^ mask[i & 3]);
ms.Write(masked, 0, len);
byte[] arr = ms.ToArray();
stream.Write(arr, 0, arr.Length); stream.Flush();
}
private string RecvMsg(Stream stream)
{
while (true)
{
int b0 = stream.ReadByte(); if (b0 < 0) return null;
int b1 = stream.ReadByte(); if (b1 < 0) return null;
int opcode = b0 & 0x0f;
long len = b1 & 0x7f;
if (len == 126) { byte[] e = ReadBytes(stream, 2); if (e == null) return null; len = (e[0] << 8) | e[1]; }
else if (len == 127) { byte[] e = ReadBytes(stream, 8); if (e == null) return null; len = 0; for (int i = 0; i < 8; i++) len = (len << 8) | e[i]; }
bool masked = (b1 & 0x80) != 0;
byte[] mask = null;
if (masked) { mask = ReadBytes(stream, 4); if (mask == null) return null; }
byte[] payload = len > 0 ? ReadBytes(stream, (int)len) : new byte[0];
if (payload == null) return null;
if (masked) for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(payload[i] ^ mask[i & 3]);
if (opcode == 0x8) return null;
if (opcode == 0x9)
{
MemoryStream ms = new MemoryStream(); ms.WriteByte(0x8A);
int l = payload.Length;
if (l < 126) ms.WriteByte((byte)(0x80 | l));
else { ms.WriteByte((byte)(0x80 | 126)); ms.WriteByte((byte)((l >> 8) & 0xff)); ms.WriteByte((byte)(l & 0xff)); }
byte[] mk = new byte[4]; _rng.NextBytes(mk); ms.Write(mk, 0, 4);
byte[] mp = new byte[l];
for (int i = 0; i < l; i++) mp[i] = (byte)(payload[i] ^ mk[i & 3]);
ms.Write(mp, 0, l);
byte[] arr = ms.ToArray();
stream.Write(arr, 0, arr.Length); stream.Flush();
continue;
}
if (opcode == 0x1) return Encoding.UTF8.GetString(payload);
}
}
public void Dispose() { _stop = true; }
}
public class PBMetrComponent : LogicComponent
{
private readonly ObsClient _obs;
private readonly LiveSplitState _state;
private volatile IniConfig _cfg;
private readonly System.Threading.Timer _cfgTimer;
public override string ComponentName { get { return "PB-metr OBS"; } }
public PBMetrComponent(LiveSplitState state)
{
_state = state;
_cfg = IniConfig.Load();
Log.W("INIT", "component constructed; obs=" + _cfg.Host + ":" + _cfg.Port + " comparison=" + _cfg.Comparison + " theme=" + _cfg.Theme);
_obs = new ObsClient(_cfg.Host, _cfg.Port, _cfg.Password);
_obs.OnIdentified = new Action(BroadcastConfig);
_state.OnStart += OnStart;
_state.OnSplit += OnSplit;
_state.OnSkipSplit += OnSkipSplit;
_state.OnReset += OnReset;
_cfgTimer = new System.Threading.Timer(new TimerCallback(CfgTick), null, 1000, 2000);
}
private void CfgTick(object o)
{
try { _cfg = IniConfig.Load(); BroadcastConfig(); }
catch { }
}
private static string Fmt(double d) { return d.ToString("0.###", CultureInfo.InvariantCulture); }
private static string Esc(string s)
{
if (s == null) return "";
return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
private void BroadcastConfig()
{
IniConfig c = _cfg;
string p = "{\"type\":\"config\",\"theme\":\"" + Esc(c.Theme) + "\",\"idle\":" + Fmt(c.Idle) +
",\"green\":" + Fmt(c.Green) + ",\"gold\":" + Fmt(c.Gold) + ",\"red\":" + Fmt(c.Red) +
",\"combo\":" + Fmt(c.Combo) + ",\"kick\":" + Fmt(c.Kick) + "}";
_obs.Broadcast(p);
}
public override void Update(IInvalidator invalidator, LiveSplitState state,
float width, float height, LayoutMode mode) { }
private void OnStart(object sender, EventArgs e)
{
Log.W("EVENT", "OnStart -> reset");
try { _obs.Broadcast("{\"type\":\"reset\"}"); } catch { }
}
private void OnReset(object sender, TimerPhase e)
{
Log.W("EVENT", "OnReset -> reset");
try { _obs.Broadcast("{\"type\":\"reset\"}"); } catch { }
}
private void OnSplit(object sender, EventArgs e)
{
try
{
int idx = _state.CurrentSplitIndex;
int completed = idx - 1;
Log.W("EVENT", "OnSplit idx=" + idx + " completed=" + completed + " runCount=" + _state.Run.Count);
if (completed >= 0 && completed < _state.Run.Count) SendSplit(_state, completed);
if (idx >= _state.Run.Count) SendFinish(_state);
}
catch (Exception ex) { Log.W("ERR", "OnSplit: " + ex.Message); }
}
private void OnSkipSplit(object sender, EventArgs e)
{
try { if (_state.CurrentSplitIndex >= _state.Run.Count) SendFinish(_state); } catch { }
}
private void SendSplit(LiveSplitState state, int c)
{
try
{
TimingMethod method = state.CurrentTimingMethod;
string cmp = _cfg.Comparison;
string comparison = (cmp == "auto" || string.IsNullOrEmpty(cmp)) ? state.CurrentComparison : cmp;
ISegment seg = state.Run[c];
TimeSpan? split = seg.SplitTime[method];
TimeSpan? prev = c > 0 ? state.Run[c - 1].SplitTime[method] : (TimeSpan?)TimeSpan.Zero;
TimeSpan? best = seg.BestSegmentTime[method];
TimeSpan? pbCum = seg.Comparisons[comparison][method];
double runDelta = (split.HasValue && pbCum.HasValue) ? (split.Value - pbCum.Value).TotalSeconds : 0.0;
TimeSpan? segTime = split.HasValue
? (TimeSpan?)(split.Value - (prev.HasValue ? prev.Value : TimeSpan.Zero))
: (TimeSpan?)null;
bool gold = segTime.HasValue && best.HasValue && segTime.Value < best.Value;
double goldMag = gold ? Math.Abs((best.Value - segTime.Value).TotalSeconds) : 0.0;
string color = gold ? "gold" : (runDelta <= 0 ? "green" : "red");
double mag = gold ? goldMag : Math.Abs(runDelta);
string name = Esc(seg.Name);
Log.W("SPLIT", color + " runDelta=" + Fmt(runDelta) + " goldMag=" + Fmt(goldMag) + " name=" + name);
string p = "{\"type\":\"split\",\"color\":\"" + color + "\",\"segmentDeltaSec\":" + Fmt(mag) +
",\"runDeltaSec\":" + Fmt(runDelta) + ",\"splitIndex\":" + c + ",\"totalSplits\":" + state.Run.Count +
",\"splitName\":\"" + name + "\"}";
_obs.Broadcast(p);
}
catch { }
}
private void SendFinish(LiveSplitState state)
{
try
{
TimingMethod method = state.CurrentTimingMethod;
int last = state.Run.Count - 1;
TimeSpan? pb = state.Run[last].PersonalBestSplitTime[method];
TimeSpan? final = state.Run[last].SplitTime[method];
if (!final.HasValue) final = state.CurrentTime[method];
bool isPB = !pb.HasValue || (final.HasValue && final.Value < pb.Value);
Log.W("FINISH", (isPB ? "PB!" : "finish") + " final=" + (final.HasValue?Fmt(final.Value.TotalSeconds):"?") + " pb=" + (pb.HasValue?Fmt(pb.Value.TotalSeconds):"?"));
_obs.Broadcast(isPB ? "{\"type\":\"pb\",\"splitName\":\"PB!\"}" : "{\"type\":\"finish\"}");
}
catch { }
}
public override void Dispose()
{
try
{
_state.OnStart -= OnStart;
_state.OnSplit -= OnSplit;
_state.OnSkipSplit -= OnSkipSplit;
_state.OnReset -= OnReset;
}
catch { }
try { if (_cfgTimer != null) _cfgTimer.Dispose(); } catch { }
if (_obs != null) _obs.Dispose();
}
public override XmlNode GetSettings(XmlDocument document) { return document.CreateElement("Settings"); }
public override Control GetSettingsControl(LayoutMode mode) { return null; }
public override void SetSettings(XmlNode settings) { }
}
}
# ============================================================================
# PB-metr OBS — settings / настройки
# EN: This file sits next to LiveSplit.PBMetrObs.dll (in LiveSplit\Components).
# The component re-reads it every 2 seconds, so the look and "feel" change
# LIVE — just save the file.
# RU: Файл лежит рядом с LiveSplit.PBMetrObs.dll (в папке LiveSplit\Components).
# Компонент перечитывает его каждые 2 секунды, поэтому внешний вид и
# "ощущения" меняются НА ЛЕТУ — просто сохрани файл.
# ============================================================================
# === CONNECTION / ПОДКЛЮЧЕНИЕ (applied on LiveSplit start / применяется при запуске LiveSplit) ===
# EN: OBS WebSocket address and port (OBS -> Tools -> WebSocket Server Settings).
# RU: Адрес и порт OBS WebSocket (OBS -> Инструменты -> Настройки WebSocket-сервера).
host=127.0.0.1
port=4455
# EN: OBS WebSocket password. Empty = no password.
# RU: Пароль OBS WebSocket. Пусто = без пароля.
password=
# EN: Comparison for green/red. auto = current LiveSplit comparison (usually Personal Best).
# RU: Сравнение для зелёного/красного. auto = текущее сравнение LiveSplit (обычно Personal Best).
comparison=auto
# === APPEARANCE / ВНЕШНИЙ ВИД (changes live / меняется на лету) ===
# EN: Theme: neon | minimal | retro
# RU: Тема: neon | minimal | retro
theme=neon
# === FEEL / ОЩУЩЕНИЯ (changes live / меняется на лету) ===
# EN: idle — meter level at rest (0..100); we start from zero.
# RU: idle — уровень метра в покое (0..100); начинаем с нуля.
idle=0
# EN: green — how much the meter grows on a green (ahead) split.
# RU: green — насколько метр растёт за зелёный (впереди) сплит.
green=6
# EN: gold — growth on a gold (best-ever segment) split.
# RU: gold — рост за золотой (лучший сегмент) сплит.
gold=12
# EN: red — how much the meter drops back on a red (behind) split.
# RU: red — откат назад за красный (позади) сплит.
red=8
# EN: combo — extra growth for each green in a row.
# RU: combo — доп. рост за каждый зелёный подряд.
combo=1.5
# EN: kick — needle snap multiplier (higher = sharper jerk).
# RU: kick — множитель силы рывка стрелки (больше = резче дёргается).
kick=1.0
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net481</TargetFramework>
<OutputType>Library</OutputType>
<AssemblyName>LiveSplit.PBMetrObs</AssemblyName>
<RootNamespace>LiveSplit.PBMetrObs</RootNamespace>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<Version>1.0.0</Version>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<EnableDynamicLoading>true</EnableDynamicLoading>
<LiveSplitDir>C:\LiveSplit</LiveSplitDir>
</PropertyGroup>
<ItemGroup>
<Compile Include="PBMetrObsComponent.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="LiveSplit.Core">
<HintPath>$(LiveSplitDir)\LiveSplit.Core.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UpdateManager">
<HintPath>$(LiveSplitDir)\UpdateManager.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xml" />
</ItemGroup>
</Project>
============================================================================
PB-metr OBS — installation (README)
A speedometer for your run, driven by LiveSplit + OBS WebSocket.
purr.games
============================================================================
IN THIS ARCHIVE
LiveSplit.PBMetrObs.dll LiveSplit component.
pbmetr-gauge.html OBS browser source (the gauge / layout).
pbmetr-obs.ini Settings: theme and feel.
README_EN.txt This file.
README_RU.txt Russian version.
STEP 1 - OBS BROWSER SOURCE
1. OBS -> Tools -> WebSocket Server Settings -> enable, port 4455.
Easiest to leave the password empty (server listens on localhost only).
2. Sources -> (+) -> Browser -> Local file -> pick pbmetr-gauge.html.
Width 520, height 360. Transparent background.
3. If OBS WebSocket has a password, add it to the URL: ?pass=YOUR_PASSWORD
STEP 2 - LIVESPLIT COMPONENT
1. Copy LiveSplit.PBMetrObs.dll and pbmetr-obs.ini into the "Components"
folder next to LiveSplit.exe.
2. LiveSplit -> right-click -> Edit Layout -> (+) -> Other -> "PB-metr OBS"
-> OK -> Save Layout.
3. Start a run and hit your splits - the needle in OBS reacts. It connects
to OBS by itself and reconnects if OBS restarts.
TUNING
Theme (neon | minimal | retro) and feel live in pbmetr-obs.ini. The
component re-reads it every ~2 seconds - change it live, mid-stream.
QUICK TEST WITHOUT LIVESPLIT
Open pbmetr-gauge.html?demo=1 in a browser.
Hotkeys: Q green, W gold, E red, R finish-PB, T reset.
Author: Purr - purr.games - Discord: purr.games
============================================================================
============================================================================
PB-metr OBS — установка (README)
Спидометр для твоего рана на LiveSplit + OBS WebSocket.
purr.games
============================================================================
В АРХИВЕ
LiveSplit.PBMetrObs.dll Компонент LiveSplit.
pbmetr-gauge.html Браузер-источник в OBS (гейдж / лайаут).
pbmetr-obs.ini Настройки: тема и «ощущения».
README_RU.txt Этот файл.
README_EN.txt Версия на английском.
ШАГ 1 — БРАУЗЕР-ИСТОЧНИК В OBS
1. OBS -> Инструменты -> Настройки WebSocket-сервера -> включить, порт 4455.
Пароль проще снять (сервер слушает только localhost).
2. Источники -> (+) -> Браузер -> Локальный файл -> выбрать pbmetr-gauge.html.
Ширина 520, высота 360. Фон прозрачный.
3. Если у OBS WebSocket есть пароль — добавь его в адрес: ?pass=ПАРОЛЬ
ШАГ 2 — КОМПОНЕНТ LIVESPLIT
1. Скопируй LiveSplit.PBMetrObs.dll и pbmetr-obs.ini в папку «Components»
рядом с LiveSplit.exe.
2. LiveSplit -> ПКМ -> Edit Layout -> (+) -> Other -> «PB-metr OBS» -> OK ->
Save Layout.
3. Запусти ран и жми сплиты — стрелка в OBS реагирует. Компонент сам
подключается к OBS и переподключается при перезапуске OBS.
НАСТРОЙКА
Тема (neon | minimal | retro) и «ощущения» — в pbmetr-obs.ini. Компонент
перечитывает файл каждые ~2 секунды — меняй прямо во время стрима.
БЫСТРАЯ ПРОВЕРКА БЕЗ LIVESPLIT
Открой pbmetr-gauge.html?demo=1 в браузере.
Клавиши: Q зелёный, W золотой, E красный, R финиш-PB, T сброс.
Автор: Purr - purr.games - Discord: purr.games
============================================================================