feat: Forza composite dashboard - RPM arc + gear + speed + pedals + fuel/temp, 16:9

This commit is contained in:
2026-07-25 16:35:03 +08:00
parent 7959d911e6
commit 29f1ec1666
2 changed files with 271 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<title>TurboSu - Forza Composite</title>
<style>
*,*::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','MiSans',sans-serif;
color:#fff;-webkit-tap-highlight-color:transparent}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden}
#box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh}
#box.center{width:auto;height:auto}
#content{position:relative;width:100%;height:100%;background:linear-gradient(165deg,#050510 0%,#0a0a1e 50%,#0d0d28 100%)}
/* ---- RPM Arc (left-center) ---- */
#rpm-arc{position:absolute;left:4%;top:8%;width:58%;height:78%}
#rpm-arc text{fill:rgba(255,255,255,.9)}
/* ---- Speed (below RPM arc) ---- */
#speed-area{position:absolute;left:4%;bottom:10%;width:58%;text-align:center}
#speed-val{font-size:min(7vw,72px);font-weight:900;line-height:1;text-shadow:0 0 40px rgba(52,130,255,.4)}
#speed-unit{font-size:min(2vw,18px);color:rgba(255,255,255,.4);letter-spacing:.15em;margin-top:2px}
/* ---- Gear (right side, large) ---- */
#gear-box{position:absolute;right:8%;top:50%;transform:translateY(-55%);
width:min(18vw,160px);height:min(18vw,160px);
border-radius:50%;display:flex;align-items:center;justify-content:center;
background:rgba(52,130,255,.08);border:3px solid rgba(52,130,255,.3);
box-shadow:0 0 60px rgba(52,130,255,.1)}
#gear-val{font-size:min(9vw,80px);font-weight:900;transition:transform .12s ease}
#gear-val.flash{color:#FF9F0A;transform:scale(1.15)}
/* ---- Throttle / Brake (top-right) ---- */
#pedal-area{position:absolute;right:4%;top:6%;width:20%;display:flex;flex-direction:column;gap:14px}
.pedal-row{display:flex;align-items:center;gap:8px}
.pedal-label{font-size:min(1.4vw,11px);color:rgba(255,255,255,.45);width:28px;text-align:right}
.pedal-bar{flex:1;height:10px;background:rgba(255,255,255,.08);border-radius:5px;overflow:hidden}
.pedal-fill{height:100%;border-radius:5px;transition:width .08s linear}
#throttle-fill{background:linear-gradient(90deg,#3482FF,#5D9BFF)}
#brake-fill{background:linear-gradient(90deg,#E94634,#FF6B5B)}
/* ---- Fuel / Water Temp (bottom-right) ---- */
#status-area{position:absolute;right:4%;bottom:8%;width:20%;display:flex;flex-direction:column;gap:16px}
.status-row{display:flex;align-items:center;gap:8px}
.status-icon{font-size:min(1.6vw,14px);width:22px;text-align:center}
.status-label{font-size:min(1.2vw,10px);color:rgba(255,255,255,.35);width:24px}
.status-bar{flex:1;height:6px;background:rgba(255,255,255,.08);border-radius:3px;overflow:hidden}
.status-fill{height:100%;border-radius:3px;transition:width .3s ease}
#fuel-fill{background:linear-gradient(90deg,#FF9F0A,#F5A623)}
#water-fill{background:linear-gradient(90deg,#34C759,#5DD875)}
.status-val{font-size:min(1.2vw,10px);color:rgba(255,255,255,.4);width:32px;text-align:right}
/* ---- boost indicator ---- */
#boost-area{position:absolute;right:4%;top:35%;width:20%}
#boost-val{font-size:min(3vw,24px);font-weight:700;color:#a78bfa}
/* ---- RPM scale marks ---- */
.rpm-mark{stroke:rgba(255,255,255,.15);stroke-width:1}
.rpm-mark-major{stroke:rgba(255,255,255,.4);stroke-width:2}
.rpm-mark-red{stroke:rgba(233,70,52,.5);stroke-width:1}
/* ---- scale text ---- */
#scale-text{position:absolute;left:8%;top:16%;font-size:min(1.2vw,11px);color:rgba(255,255,255,.25)}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<!-- RPM Arc -->
<svg id="rpm-arc" viewBox="0 0 600 400" preserveAspectRatio="xMidYMid meet"></svg>
<div id="scale-text">×1000 rpm</div>
<!-- Speed -->
<div id="speed-area">
<div id="speed-val">0</div>
<div id="speed-unit">KM/H</div>
</div>
<!-- Gear -->
<div id="gear-box"><span id="gear-val">1</span></div>
<!-- Pedals (throttle/brake) -->
<div id="pedal-area">
<div class="pedal-row">
<span class="pedal-label">油门</span>
<div class="pedal-bar"><div class="pedal-fill" id="throttle-fill" style="width:0%"></div></div>
</div>
<div class="pedal-row">
<span class="pedal-label">刹车</span>
<div class="pedal-bar"><div class="pedal-fill" id="brake-fill" style="width:0%"></div></div>
</div>
</div>
<!-- Fuel / Water -->
<div id="status-area">
<div class="status-row">
<span class="status-icon"></span>
<span class="status-label">FUEL</span>
<div class="status-bar"><div class="status-fill" id="fuel-fill" style="width:100%"></div></div>
<span class="status-val" id="fuel-val">100%</span>
</div>
<div class="status-row">
<span class="status-icon">🌡</span>
<span class="status-label">TEMP</span>
<div class="status-bar"><div class="status-fill" id="water-fill" style="width:30%"></div></div>
<span class="status-val" id="water-val">--</span>
</div>
</div>
</div></div></div>
<script>
const M={aspect_ratio:"16:9",render_mode:"contain"};
let GAME_ID='';
(function(){
const box=document.getElementById('box'),arcSvg=document.getElementById('rpm-arc'),
speedV=document.getElementById('speed-val'),speedU=document.getElementById('speed-unit'),
gearV=document.getElementById('gear-val'),gearBox=document.getElementById('gear-box'),
tFill=document.getElementById('throttle-fill'),bFill=document.getElementById('brake-fill'),
fFill=document.getElementById('fuel-fill'),fVal=document.getElementById('fuel-val'),
wFill=document.getElementById('water-fill'),wVal=document.getElementById('water-val');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt,lastGear=-999,lastRpm=-1;
// Arc geometry (in viewBox 600x400 coords)
const CX=300,CY=340,R=260;
const ARC_START=-205,ARC_END=-25; // degrees, from -205° (bottom-left) to -25° (bottom-right)
function degToRad(d){return d*Math.PI/180}
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 apply(){
const ratio=parseRatio(M.aspect_ratio||'16:9'),mode=M.render_mode||'contain';
if(!ratio){box.style.width='100vw';box.style.height='100vh';box.className='fill';return;}
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
switch(mode){
case'cover':if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover';break;
case'fill':box.style.width='100vw';box.style.height='100vh';box.className='fill';return;
case'center':cw=Math.min(vw,1920);ch=cw/ratio;if(ch>vh){ch=Math.min(vh,1080);cw=ch*ratio};box.className='center';break;
default:if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio};box.className='contain';
}
if(mode!=='fill'){box.style.width=cw+'px';box.style.height=ch+'px';}
drawArc();
}
function drawArc(maxRpm,curRpm,gear){
maxRpm=maxRpm||8000;curRpm=curRpm||0;
const redline=Math.max(maxRpm*0.85,maxRpm-500);
let svg='';
// arc background
svg+=`<path d="${arcPath(ARC_START,ARC_END,R)}" fill="none" stroke="rgba(255,255,255,.06)" stroke-width="28" stroke-linecap="round"/>`;
// gear segment demarcations (every 1000 rpm)
const totalAngle=ARC_END-ARC_START;
for(let r=0;r<=maxRpm;r+=1000){
const pct=r/maxRpm;
const ang=ARC_START+totalAngle*pct;
const a=degToRad(ang);
const x1=CX+(R-24)*Math.cos(a),y1=CY+(R-24)*Math.sin(a);
const x2=CX+(R+24)*Math.cos(a),y2=CY+(R+24)*Math.sin(a);
const color=r>=redline?'rgba(233,70,52,.4)':(r%2000===0?'rgba(255,255,255,.35)':'rgba(255,255,255,.12)');
const sw=r%2000===0?2:1;
svg+=`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${sw}"/>`;
if(r%2000===0){
const lx=CX+(R-44)*Math.cos(a),ly=CY+(R-44)*Math.sin(a);
svg+=`<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" fill="rgba(255,255,255,.35)">${r/1000}</text>`;
}
}
// RPM filled arc
if(curRpm>0){
const pct=Math.min(curRpm/maxRpm,1);
const endAng=ARC_START+totalAngle*pct;
const color=curRpm>=redline?'#E94634':(curRpm>=redline*0.7?'#FF9F0A':'#3482FF');
svg+=`<path d="${arcPath(ARC_START,endAng,R)}" fill="none" stroke="${color}" stroke-width="28" stroke-linecap="round"/>`;
svg+=`<filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`;
svg+=`<path d="${arcPath(endAng-1,endAng,R)}" fill="none" stroke="${color}" stroke-width="32" stroke-linecap="round" filter="url(#glow)" opacity=".6"/>`;
}
// RPM numeric value center
svg+=`<text x="${CX}" y="${CY-8}" text-anchor="middle" font-size="28" font-weight="700" fill="#fff">${Math.round(curRpm).toLocaleString()}</text>`;
svg+=`<text x="${CX}" y="${CY+14}" text-anchor="middle" font-size="12" fill="rgba(255,255,255,.35)">RPM</text>`;
arcSvg.innerHTML=svg;
}
function arcPath(startDeg,endDeg,r){
const sa=degToRad(startDeg),ea=degToRad(endDeg);
const x1=CX+r*Math.cos(sa),y1=CY+r*Math.sin(sa);
const x2=CX+r*Math.cos(ea),y2=CY+r*Math.sin(ea);
const sweep=endDeg-startDeg>180?1:0;
return `M ${x1} ${y1} A ${r} ${r} 0 ${sweep} 1 ${x2} ${y2}`;
}
function connect(){
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==='connected'){GAME_ID=m.selected_game_id||''}
if(m.type==='telemetry')update(m.data)
}catch(e){}};
ws.onclose=()=>{rt=setTimeout(connect,2000)};
}
function update(d){
const rpm=d.rpm||0,maxRpm=d.max_rpm||8000,gear=d.gear||0;
const speed=d.speed_kmh||0;
// RPM arc
if(Math.abs(rpm-lastRpm)>50||gear!==lastGear){
lastRpm=rpm;lastGear=gear;
drawArc(maxRpm,rpm,gear);
}
// Speed
speedV.textContent=Math.round(speed);
speedU.textContent='KM/H';
// Gear
const gv=gear===0?'R':String(gear);
if(gv!==gearV.textContent){
gearV.textContent=gv;
gearBox.style.animation='none';gearBox.offsetHeight;gearBox.style.animation='';
gearV.classList.add('flash');setTimeout(()=>gearV.classList.remove('flash'),150);
}
// Throttle / Brake
tFill.style.width=(d.throttle*100).toFixed(0)+'%';
bFill.style.width=(d.brake*100).toFixed(0)+'%';
// Fuel
const fuelPct=Math.round((d.fuel||1)*100);
fFill.style.width=fuelPct+'%';
fVal.textContent=fuelPct+'%';
// Water temp (estimate from engine temp or oil temp raw)
// Forza doesn't send exact water temp, using engine_temp if available
const et=d.engine_temp||0;
if(et>0){
const pct=Math.min(et/120,1)*100;
wFill.style.width=pct+'%';
wVal.textContent=Math.round(et)+'°C';
}
}
drawArc(8000,0,1);
apply();window.addEventListener('resize',apply);connect();
})();
</script>
</body>
</html>