179 lines
8.4 KiB
HTML
179 lines
8.4 KiB
HTML
<!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>
|
|
@font-face{font-family:'NEXT ART';src:url('/static/fonts/NEXT_ART_Heavy.otf') format('opentype')}
|
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
|
html,body{width:100%;height:100%;overflow:hidden;background:#060a16;color:#fff;
|
|
font-family:'NEXT ART',monospace}
|
|
#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}
|
|
#content{position:relative;width:100%;height:100%;background:#060a16}
|
|
#rpm-area{position:absolute;left:2%;top:4%;width:62%;height:82%}
|
|
#rpm-canvas{width:100%;height:100%}
|
|
#speed-area{position:absolute;left:4%;bottom:10%;width:60%;text-align:center}
|
|
#speed-val{font-size:min(24vw,240px);font-weight:200;line-height:1;letter-spacing:-.02em}
|
|
#speed-unit{font-size:min(6vw,44px);color:rgba(255,255,255,.2);letter-spacing:.25em;margin-top:4px}
|
|
#gear-area{position:absolute;right:12%;top:50%;transform:translateY(-55%)}
|
|
#gear-box{width:min(24vw,200px);height:min(24vw,200px);border-radius:50%;border:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center}
|
|
#gear-box span{font-size:min(48vw,400px);font-weight:200;transition:.1s}
|
|
#pedals{position:absolute;right:4%;top:6%;width:16%}
|
|
.pedal{margin-bottom:22px}
|
|
.pedal .pl{font-size:min(4.8vw,34px);color:rgba(255,255,255,.25);margin-bottom:6px;letter-spacing:.15em}
|
|
.pedal .pb{height:12px;background:rgba(255,255,255,.06);overflow:hidden}
|
|
.pedal .pf{height:100%;transition:width .06s linear}
|
|
#tf{background:#fff}#bf{background:rgba(255,255,255,.4)}
|
|
#status{position:absolute;right:4%;bottom:8%;width:16%}
|
|
.st{margin-bottom:18px}
|
|
.st .sl{font-size:min(4.4vw,30px);color:rgba(255,255,255,.25);margin-bottom:5px;display:flex;justify-content:space-between}
|
|
.st .sb{height:6px;background:rgba(255,255,255,.06);overflow:hidden}
|
|
.st .sf{height:100%;transition:width .3s}
|
|
#ff{background:rgba(255,255,255,.3)}#wf{background:rgba(255,255,255,.15)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"><div id="box" class="contain"><div id="content">
|
|
<div id="rpm-area"><canvas id="rpm-canvas"></canvas></div>
|
|
<div id="speed-area"><div id="speed-val">0</div><div id="speed-unit">KM/H</div></div>
|
|
<div id="gear-area"><div id="gear-box"><span id="gear-val">1</span></div></div>
|
|
<div id="pedals">
|
|
<div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div>
|
|
<div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div>
|
|
</div>
|
|
<div id="status">
|
|
<div class="st"><div class="sl"><span>FUEL</span><span id="fv">100%</span></div><div class="sb"><div class="sf" id="ff" style="width:100%"></div></div></div>
|
|
<div class="st"><div class="sl"><span>TEMP</span><span id="wv">--</span></div><div class="sb"><div class="sf" id="wf" style="width:20%"></div></div></div>
|
|
</div>
|
|
</div></div></div>
|
|
<script>
|
|
const M={aspect_ratio:"16:9",render_mode:"contain"};let GAME_ID='';
|
|
(function(){
|
|
const box=document.getElementById('box'),canvas=document.getElementById('rpm-canvas'),
|
|
sV=document.getElementById('speed-val'),sU=document.getElementById('speed-unit'),
|
|
gV=document.getElementById('gear-val'),gBox=document.getElementById('gear-box'),
|
|
tF=document.getElementById('tf'),bF=document.getElementById('bf'),
|
|
fF=document.getElementById('ff'),fV=document.getElementById('fv'),
|
|
wF=document.getElementById('wf'),wV=document.getElementById('wv');
|
|
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
|
const ctx=canvas.getContext('2d');
|
|
let ws,rt,lR=-1,lG=-999;
|
|
const Ca=360,Cb=420,Cr=300,As=180,Ae=270;
|
|
|
|
function d2r(d){return d*Math.PI/180}
|
|
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
|
|
|
function apply(){
|
|
const rt=pr(M.aspect_ratio||'16:9'),md=M.render_mode||'contain';
|
|
if(!rt){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;
|
|
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
|
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
|
box.style.width=cw+'px';box.style.height=ch+'px'
|
|
}
|
|
|
|
function sz(){
|
|
const r=canvas.parentElement.getBoundingClientRect(),d=window.devicePixelRatio||1;
|
|
canvas.width=r.width*d;canvas.height=r.height*d;
|
|
canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';
|
|
ctx.setTransform(d,0,0,d,0,0);
|
|
}
|
|
|
|
function draw(cur,max){
|
|
max=max||8000;cur=cur||0;sz();
|
|
const W=canvas.width/devicePixelRatio,H=canvas.height/devicePixelRatio;
|
|
ctx.clearRect(0,0,W,H);
|
|
const sx=Ca/640*W,sy=Cb/480*H,sr=Cr/640*W;
|
|
const gw=Math.max(36,W/18);
|
|
const ae=d2r(Ae),ex=sx+sr*Math.cos(ae),ey=sy+sr*Math.sin(ae),lx=W*.93;
|
|
const arcLen=Math.PI*2*sr*((Ae-As)/360),lineLen=lx-ex,totalLen=arcLen+lineLen;
|
|
const rl=max*.85;
|
|
|
|
// point on total path (0→1 mapped to arc+line)
|
|
function pathPt(t){
|
|
const ad=t*totalLen;
|
|
if(ad<=arcLen){
|
|
const a=d2r(As+(Ae-As)*(ad/arcLen));
|
|
return{x:sx+sr*Math.cos(a),y:sy+sr*Math.sin(a)};
|
|
}
|
|
const lp=(ad-arcLen)/lineLen;
|
|
return{x:ex+lp*(lx-ex),y:ey};
|
|
}
|
|
|
|
// background: arc + line
|
|
ctx.beginPath();
|
|
ctx.arc(sx,sy,sr,d2r(As),d2r(Ae),false);
|
|
ctx.lineTo(lx,ey);
|
|
ctx.strokeStyle='rgba(255,255,255,.04)';
|
|
ctx.lineWidth=gw;
|
|
ctx.stroke();
|
|
|
|
// progress along full path
|
|
const pc=Math.min(cur/max,1);
|
|
if(pc>.005){
|
|
const ep=pathPt(pc);
|
|
ctx.beginPath();
|
|
const ad=pc*totalLen;
|
|
if(ad<=arcLen){
|
|
ctx.arc(sx,sy,sr,d2r(As),d2r(As+(Ae-As)*(ad/arcLen)),false);
|
|
}else{
|
|
ctx.arc(sx,sy,sr,d2r(As),d2r(Ae),false);
|
|
ctx.lineTo(ep.x,ey);
|
|
}
|
|
ctx.strokeStyle=cur>=rl?'rgba(255,50,35,.95)':'rgba(255,255,255,.85)';
|
|
ctx.lineWidth=gw;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// scale marks along TOTAL path (arc + line)
|
|
const mr=sr-gw/2-6,pr=sr+gw/2+6;
|
|
for(let r=0;r<=max;r+=2000){
|
|
const t=r/max,ad=t*totalLen;
|
|
if(ad<=arcLen){
|
|
const a=d2r(As+(Ae-As)*(ad/arcLen));
|
|
ctx.beginPath();ctx.moveTo(sx+mr*Math.cos(a),sy+mr*Math.sin(a));ctx.lineTo(sx+pr*Math.cos(a),sy+pr*Math.sin(a));
|
|
ctx.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)';
|
|
ctx.lineWidth=r%4000===0?2.5:1;ctx.stroke();
|
|
if(r%4000===0){ctx.fillStyle='rgba(255,255,255,.22)';ctx.font=`${Math.max(26,W/19)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';ctx.fillText(Math.round(r/1000),sx+(pr+22)*Math.cos(a),sy+(pr+22)*Math.sin(a)+5)}
|
|
}else{
|
|
const p=pathPt(t);
|
|
ctx.beginPath();ctx.moveTo(p.x,ey-gw/2-4);ctx.lineTo(p.x,ey+gw/2+4);
|
|
ctx.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)';
|
|
ctx.lineWidth=r%4000===0?2.5:1;ctx.stroke();
|
|
if(r%4000===0){ctx.fillStyle='rgba(255,255,255,.22)';ctx.font=`${Math.max(26,W/19)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';ctx.fillText(Math.round(r/1000),p.x,ey+gw/2+22)}
|
|
}
|
|
}
|
|
|
|
// RPM number
|
|
ctx.fillStyle='#fff';ctx.font=`200 ${Math.max(128,W/4.5)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';
|
|
ctx.fillText(Math.round(cur).toLocaleString(),sx,sy-210);
|
|
ctx.fillStyle='rgba(255,255,255,.12)';ctx.font=`${Math.max(32,W/17)|0}px 'NEXT ART',sans-serif`;
|
|
ctx.fillText('\u00d71000 rpm',sx,sy-150);
|
|
|
|
// redline
|
|
ctx.strokeStyle='rgba(255,40,30,.1)';ctx.lineWidth=1;ctx.setLineDash([3,6]);
|
|
ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(sx+sr*Math.cos(d2r(As+(Ae-As)*.85)),sy+sr*Math.sin(d2r(As+(Ae-As)*.85)));ctx.stroke();ctx.setLineDash([])
|
|
}
|
|
|
|
function cn(){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')up(m.data)}catch(e){}}
|
|
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
|
|
|
function up(d){const rpm=d.rpm||0,mr=d.max_rpm||8000,g=d.gear||0,sp=d.speed_kmh||0;
|
|
if(Math.abs(rpm-lR)>25||g!==lG){lR=rpm;lG=g;draw(rpm,mr)}
|
|
sV.textContent=Math.round(sp);gV.textContent=g===0?'N':g===11?'R':g===21?'P':String(g);
|
|
tF.style.width=(d.throttle*100).toFixed(0)+'%';bF.style.width=(d.brake*100).toFixed(0)+'%';
|
|
const fp=Math.round((d.fuel||1)*100);fF.style.width=fp+'%';fV.textContent=fp+'%';
|
|
const et=d.engine_temp||0;if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}}
|
|
|
|
draw(0,8000);apply();window.addEventListener('resize',()=>{apply();draw(lR>0?lR:0,8000)});cn()
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|