← Ко всем проектам бета

ПБ-Метр на стрим

Стрелочный спидометр прогресса спидрана прямо на сцене OBS. С каждым сплитом стрелка дёргается: зелёный — метр растёт, золотой — рывок вверх, красный — отскок. Финиш с новым PB — метр улетает в 100 и искрится.

Всё общение идёт через OBS WebSocket — отдельный веб-сервер не нужен, а вся логика живёт в LiveSplit-компоненте.

PB meter gauge демо · золотой сплит

Скачать компонент

Всё для стрима в одном архиве: компонент .dll, лайаут pbmetr-gauge.html, настройки pbmetr-obs.ini и отдельные README_EN.txt / README_RU.txt с установкой. Исходники — ниже, по файлам.

LiveSplit.PBMetrObs.dll pbmetr-gauge.html pbmetr-obs.ini README_EN.txt README_RU.txt
Установка

Запуск за пару минут

Два шага: браузер-источник в OBS и компонент в LiveSplit.

1

Браузер-источник в OBS

  1. OBS → Инструменты → Настройки WebSocket-сервера → включить, порт 4455. Пароль проще снять (сервер слушает только localhost).
  2. Источники → (+) → Браузер → Локальный файл → выбрать pbmetr-gauge.html. Ширина 520, высота 360, фон прозрачный.
  3. Тему и «ощущения» в URL задавать не нужно — всё в pbmetr-obs.ini. Если у OBS есть пароль — добавь ?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 и переподключается при перезапуске.
Настройка на лету. Тема (neon / minimal / retro) и «ощущения» (рост, отскок, сила рывка) живут в pbmetr-obs.ini. Компонент перечитывает файл каждые ~2 секунды — меняй прямо во время стрима, ничего перезапускать не нужно. Пересобрать .dll из исходников можно через build.bat — встроенным в Windows компилятором, без установки .NET SDK.
Открытые исходники

Исходники компонента

Всё, что внутри архива — читаемо прямо здесь.

PB-metr-OBS / исходники скачивание — по файлам ниже
pbmetr-gauge.html Гейдж — браузер-источник OBS642 строк · 24.7 KB
<!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>
PBMetrObsComponent.cs Исходник компонента LiveSplit442 строк · 19.8 KB
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) { }
    }
}
pbmetr-obs.ini Настройки (комментарии EN + RU)49 строк · 2.7 KB
# ============================================================================
#  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
PBMetrObs.csproj Проект сборки .NET40 строк · 1.2 KB
<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>
README_EN.txt README по установке (EN)39 строк · 1.7 KB
============================================================================
 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
============================================================================
README_RU.txt README по установке (RU)39 строк · 2.3 KB
============================================================================
 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
============================================================================