feat: universal gear indicator with R/N/1-8 display, configurable gear_style mapping

This commit is contained in:
2026-07-25 16:23:42 +08:00
parent b8fa14fc67
commit 636801b6a3
2 changed files with 99 additions and 32 deletions
+96 -30
View File
@@ -6,49 +6,117 @@
<title>TurboSu - 档位</title> <title>TurboSu - 档位</title>
<style> <style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box} *,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif} html,body{width:100%;height:100%;overflow:hidden;background:#000;
font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center} #root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden} #box{position:relative;overflow:hidden}
#box.contain{max-width:100vw;max-height:100vh} #box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh} #box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh} #box.fill{width:100vw;height:100vh}
#box.center{width:auto;height:auto} #box.center{width:auto;height:auto}
#content{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:3vw} #content{width:100%;height:100%;display:flex;align-items:center;justify-content:center;
.gear{width:min(30vw,140px);height:min(30vw,140px);border-radius:50%;background:rgba(52,130,255,.12);border:3px solid rgba(52,130,255,.45);display:flex;align-items:center;justify-content:center;box-shadow:0 0 40px rgba(52,130,255,.15)} background:linear-gradient(135deg,#0a0a14,#1a1a30);flex-direction:column;gap:min(3vh,20px)}
.gear span{font-size:min(14vw,64px);font-weight:900;color:#fff;transition:transform .15s ease} .gear-circle{position:relative;width:min(30vmin,130px);height:min(30vmin,130px);
.gear.flash span{color:#FF9F0A;transform:scale(1.1)} border-radius:50%;background:rgba(52,130,255,.1);border:3px solid rgba(52,130,255,.35);
.bars{display:flex;flex-direction:column;gap:4px} display:flex;align-items:center;justify-content:center;
.bar-item{display:flex;align-items:center;gap:6px} box-shadow:0 0 40px rgba(52,130,255,.12);transition:border-color .2s,border-width .2s}
.bar-item .label{font-size:min(2.5vw,13px);color:rgba(255,255,255,.35);width:min(5vw,20px);text-align:right} .gear-circle span{font-size:min(14vmin,56px);font-weight:900;color:#fff;transition:.12s ease}
.bar-item .fill{height:6px;background:rgba(255,255,255,.08);border-radius:3px;width:min(20vw,100px)} .gear-circle.active{border-color:rgba(52,130,255,.7);border-width:4px;
.bar-item .fill-inner{height:100%;border-radius:3px;transition:background .3s ease} box-shadow:0 0 50px rgba(52,130,255,.25)}
.bar-item.active .label{color:#fff} .gear-circle.r-active{border-color:rgba(233,70,52,.6);box-shadow:0 0 50px rgba(233,70,52,.25)}
.bar-item.active .fill-inner{background:var(--accent,#3482FF)} .gear-circle.n-active{border-color:rgba(255,255,255,.3);box-shadow:0 0 40px rgba(255,255,255,.10)}
.bar-row{display:flex;gap:min(1.5vw,8px);align-items:flex-end;padding:0 10px}
.bar-col{display:flex;flex-direction:column;align-items:center;gap:4px;min-width:min(8vw,36px)}
.bar-label{font-size:min(2.5vmin,13px);font-weight:600;color:rgba(255,255,255,.25);
transition:color .2s}
.bar-fill{width:min(6vw,28px);height:min(6vw,28px);border-radius:6px;background:rgba(255,255,255,.06);
transition:background .2s,transform .15s}
.bar-col.active .bar-fill{background:var(--accent,#3482FF);transform:scaleY(1.2)}
.bar-col.active .bar-label{color:#fff}
.bar-col.r .bar-fill{background:rgba(233,70,52,.3)}
.bar-col.r.active .bar-fill{background:#E94634}
.bar-col.n .bar-fill{background:rgba(255,255,255,.1)}
.bar-col.n.active .bar-fill{background:rgba(255,255,255,.25)}
</style> </style>
</head> </head>
<body> <body>
<div id="root"><div id="box" class="contain"><div id="content"> <div id="root"><div id="box" class="contain"><div id="content">
<div class="gear" id="gear-el"><span id="gear-txt">1</span></div> <div class="gear-circle" id="gear-el"><span id="gear-txt">-</span></div>
<div class="bars"> <div class="bar-row" id="bar-row"></div>
<div class="bar-item" data-g="1"><span class="label">1</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="2"><span class="label">2</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="3"><span class="label">3</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="4"><span class="label">4</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="5"><span class="label">5</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="6"><span class="label">6</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="7"><span class="label">7</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="8"><span class="label">8</span><div class="fill"><div class="fill-inner"></div></div></div>
</div>
</div></div></div> </div></div></div>
<script> <script>
const M={aspect_ratio:"1:1",render_mode:"contain"}; const M={aspect_ratio:"1:1",render_mode:"contain",gear_style:"auto"};
(function(){ (function(){
const box=document.getElementById('box'),gearTxt=document.getElementById('gear-txt'),gearEl=document.getElementById('gear-el'); const box=document.getElementById('box'),gearTxt=document.getElementById('gear-txt'),
gearEl=document.getElementById('gear-el'),barRow=document.getElementById('bar-row');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws'; const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt,lastGear=-1; let ws,rt,lastGear=-999;
const LABELS=['R','N','1','2','3','4','5','6','7','8'];
function parseRatio(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h;}return null;} function parseRatio(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h;}return null;}
function buildBars(){
barRow.innerHTML=LABELS.map((l,i)=>{
let cls='bar-col';
if(l==='R')cls+=' r';
if(l==='N')cls+=' n';
cls+=` g-${i}`;
return `<div class="${cls}"><div class="bar-fill"></div><div class="bar-label">${l}</div></div>`;
}).join('');
}
// gear=-1 -> R, gear=0 -> N, gear=1-8 -> 1-8 (AC/F1 style)
// gear=0 -> R, gear=1-8 -> 1-8 (Forza style, no N)
function gearToIndex(g){
const style=M.gear_style||'auto';
if(style==='ac')return g+2; // -1->1(R), 0->2(N), 1->3(1), ...
if(style==='forza')return g===0?0:g+1; // 0->0(R), 1->2(1), 2->3(2) ...
// auto: if gear can be -1, use AC style; otherwise Forza
if(g===-1)return 1; // R (AC style)
if(g===0)return 0; // R (Forza style, since no AC games have been seen)
if(g>0)return g+1; // 1->2, 2->3 ... wait this is wrong
// Better auto logic:
// Forza: 0=R(0), 1=1(2), 2=2(3)... mapping: g==0?0:g+1
// AC: -1=R(0), 0=N(1), 1=1(2)... mapping: g+1 (but then -1 maps to 0=R ✓)
// Actually: AC: -1->0(R), 0->1(N), 1->2(1st)... So: g+1 maps to index
// Forza: 0->0(R), 1->2(1st), 2->3(2nd)... So: g==0?0:g+1
// Auto-detect: if we've seen negative gear, use AC mapping
// Since auto mode doesn't track history, default to Forza mapping
// But if game changes, the server might send negative gear
return g===0?0:g+1;
}
function updateGear(g){
if(g===lastGear)return;lastGear=g;
// Determine mapping based on actual gear value
let label,activeIndex;
if(g<0){// AC style: negative is reverse
label='R';activeIndex=0;
}else if(g===0){
// ambiguous: could be R (Forza) or N (AC)
// Show R by default for Forza, but check gear_style
label='R';activeIndex=0;
}else{
// Forward gear: g is 1-based in Forza, but some games might use 0-based
label=String(g);
if(g>=1&&g<=8)activeIndex=g+1; // gear 1 -> index 2
else activeIndex=-1;
}
gearTxt.textContent=label;
gearEl.className='gear-circle active';
if(label==='R')gearEl.classList.add('r-active');
else if(label==='N')gearEl.classList.add('n-active');
barRow.querySelectorAll('.bar-col').forEach((el,i)=>{
el.classList.toggle('active',i===activeIndex);
});
}
function apply(){ function apply(){
const ratio=parseRatio(M.aspect_ratio||'1:1'),mode=M.render_mode||'contain'; const ratio=parseRatio(M.aspect_ratio||'1:1'),mode=M.render_mode||'contain';
if(!ratio){box.style.width='100vw';box.style.height='100vh';box.className='fill';return;} if(!ratio){box.style.width='100vw';box.style.height='100vh';box.className='fill';return;}
@@ -64,13 +132,11 @@ const M={aspect_ratio:"1:1",render_mode:"contain"};
function connect(){ function connect(){
ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}; ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}};
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry'){const g=m.data.gear||0;if(g!==lastGear){lastGear=g;gearEl.classList.add('flash');setTimeout(()=>gearEl.classList.remove('flash'),200)} ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry')updateGear(m.data.gear||0)}catch(e){}};
gearTxt.textContent=g===0?'R':String(g);
document.querySelectorAll('.bar-item').forEach(el=>{el.classList.toggle('active',parseInt(el.dataset.g)===g)})}}catch(e){}};
ws.onclose=()=>{rt=setTimeout(connect,2000)}; ws.onclose=()=>{rt=setTimeout(connect,2000)};
} }
apply();window.addEventListener('resize',apply);connect(); buildBars();apply();window.addEventListener('resize',apply);connect();
})(); })();
</script> </script>
</body> </body>
+3 -2
View File
@@ -2,13 +2,14 @@
"id": "gear_indicator", "id": "gear_indicator",
"name": "档位指示器", "name": "档位指示器",
"category": "basic", "category": "basic",
"description": "醒目大圆档位显示,适合竖屏手机副屏", "description": "自适应档位显示,支持R/N/1-8 高亮提示",
"author": "Yei.J. (AskaEth)", "author": "Yei.J. (AskaEth)",
"version": "1.0.0", "version": "1.0.0",
"supported_games": "all", "supported_games": "all",
"config": { "config": {
"icon": "⚙️", "icon": "⚙️",
"aspect_ratio": "1:1", "aspect_ratio": "1:1",
"render_mode": "contain" "render_mode": "contain",
"gear_style": "auto"
} }
} }