Counter-Strike esports manager: career mode + LIVE tactical top-down simulation

- Full manager sim: 28 orgs / 168 players, calendar, transfers, youth scouting, loans, training, board confidence
- Simulation Mode with real-time micro-engine (live.js): A* pathfinding on map geometry, raycast ballistics (spread/falloff/armor/headshots), smokes/flashes/HE that matter, plant/defuse/retake AI, clutch detection
- Canvas renderer (viz.js): follow/radar cameras, HP bars, CS2-style kill feed, callouts, synthesized Web-Audio SFX, speed x1-x3, AUTO-SPECTATE hands-free series playback
- Round results flow from the firefight back into the career engine (score, economy, box score, MVP)
- Playwright + Node VM test suites included (test/, plus external pwtest harness)
This commit is contained in:
deepseek
2026-08-23 07:00:47 +00:00
commit 4c7fe1db4c
15 changed files with 4722 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
/* ============================================================
VIZ — canvas renderer + host loop for LIVE tactical rounds
Simulation logic lives in live.js; this file draws the world,
plays synthesized audio, and manages cameras/speed.
============================================================ */
'use strict';
const VIZ={
ok:false,
R:null, // {st, cv, ctx, onDone, lastTs}
cam:'follow',
speed:1,
sndOn:true,
snd:null,
init(){try{const c=document.createElement('canvas');this.ok=!!(c.getContext&&c.getContext('2d'));}catch(e){this.ok=false;}return this.ok;},
setCam(m){this.cam=m;},
cycleSpeed(){this.speed=this.speed===1?2:this.speed===2?3:1;return this.speed;},
/* ---------- synthesized audio ---------- */
ac(){
if(!this.sndOn)return null;
if(!this.snd){try{this.snd=new (window.AudioContext||window.webkitAudioContext)();}catch(e){return null;}}
if(this.snd.state==='suspended')this.snd.resume();
return this.snd;
},
_noise(dur,freq,q,vol,type){
type=type||'bandpass';
const ac=this.ac();if(!ac)return;
const n=Math.max(64,Math.floor(ac.sampleRate*dur));
const buf=ac.createBuffer(1,n,ac.sampleRate),d=buf.getChannelData(0);
for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*(1-i/n);
const src=ac.createBufferSource();src.buffer=buf;
const f=ac.createBiquadFilter();f.type=type;f.frequency.value=freq;f.Q.value=q;
const g=ac.createGain();
g.gain.setValueAtTime(vol,ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,ac.currentTime+dur);
src.connect(f);f.connect(g);g.connect(ac.destination);
src.start();
},
tone(freq,dur,vol,type,slide){
vol=vol||0.12;type=type||'square';
const ac=this.ac();if(!ac)return;
const o=ac.createOscillator(),g=ac.createGain();
o.type=type;o.frequency.setValueAtTime(freq,ac.currentTime);
if(slide)o.frequency.exponentialRampToValueAtTime(slide,ac.currentTime+dur);
g.gain.setValueAtTime(vol,ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,ac.currentTime+dur);
o.connect(g);g.connect(ac.destination);o.start();o.stop(ac.currentTime+dur);
},
sShot(big){if(this.sndOn)this._noise(big?0.15:0.08,big?850:1700,0.8,0.3);},
sHit(){if(this.sndOn)this.tone(300,0.05,0.09,'square');},
sDeath(){if(this.sndOn)this.tone(90,0.13,0.22,'sine',50);},
sPlant(){if(this.sndOn){this.tone(1250,0.06,0.1);setTimeout(()=>this.tone(1250,0.06,0.1),95);}},
sBoom(){if(this.sndOn)this._noise(0.6,210,0.6,0.45,'lowpass');},
sFlash(){if(this.sndOn)this.tone(2400,0.25,0.12,'sine',3400);},
sBeep(fast){if(this.sndOn)this.tone(fast?1600:1150,0.05,0.08);},
sWinSting(good){
if(!this.sndOn)return;
(good?[520,660,880]:[420,330,247]).forEach((f,i)=>setTimeout(()=>this.tone(f,0.16,0.13,'triangle'),i*110));
},
/* ---------- host loop ---------- */
timescale(){return this.speed===1?7:this.speed===2?14:21;},
play(cfg,onDone){
if(!this.ok||typeof LIVE==='undefined'){onDone(null);return;}
this.stop();
const cv=document.getElementById('simCanvas');
if(!cv){onDone(null);return;}
let st;
try{st=LIVE.makeRound(cfg);}
catch(e){console.warn('live makeRound failed',e);onDone(null);return;}
this.R={st,cv,ctx:cv.getContext('2d'),onDone,lastTs:performance.now(),_nextBeep:0};
const loop=(ts)=>{
const R=this.R;if(!R)return;
let dt=(ts-R.lastTs)/1000;R.lastTs=ts;
dt=Math.min(dt,0.06);
const gdt=dt*this.timescale();
let remain=gdt;
while(remain>0&&!R.st.ended){
LIVE.step(R.st,Math.min(remain,0.05));
remain-=0.05;
}
if(R.st.ended)R.st.endT+=gdt;
this.draw(R.st);
if(R.st.planted&&!R.st.ended){
const iv=R.st.bombT<10?260:520;
if(ts>R._nextBeep){R._nextBeep=ts+iv;this.sBeep(R.st.bombT<10);}
}
if(R.st.ended&&R.st.endT>=1.15){
const st=R.st;this.stop();
try{onDone(st);}catch(e){console.warn('viz onDone error',e);}
return;
}
R.raf=requestAnimationFrame(loop);
};
this.R.raf=requestAnimationFrame(loop);
},
skip(){
const R=this.R;if(!R)return;
if(R.raf)cancelAnimationFrame(R.raf);
let guard=0;
while(!R.st.ended&&guard++<6000)LIVE.step(R.st,0.05);
this.draw(R.st);
setTimeout(()=>{if(this.R===R){const cb=R.onDone,st=R.st;this.stop();cb(st);}},120);
},
stop(){if(this.R&&this.R.raf)cancelAnimationFrame(this.R.raf);this.R=null;},
/* ---------- camera ---------- */
camFor(st,W,H){
let scale=1,ox=0,oy=0;
if(this.cam==='follow'){
const alive=st.agents.filter(a=>!a.dead);
const cx=alive.reduce((s,a)=>s+a.x,0)/Math.max(alive.length,1);
const cy=alive.reduce((s,a)=>s+a.y,0)/Math.max(alive.length,1);
scale=2.1;
ox=W/2-this._sx(st.L,U.clamp(cx,24,st.L.w-24),W,H)*scale;
oy=H/2-this._sy(st.L,U.clamp(cy,18,st.L.h-18),W,H)*scale;
ox=U.clamp(ox,W-W*scale+6,6);oy=U.clamp(oy,H-H*scale+6,6);
}
return{scale:scale,ox:ox,oy:oy};
},
_sx(L,x,W,H){return 8+x/L.w*(W-16);},
_sy(L,y,W,H){return 8+y/L.h*(H-16);},
/* ---------- main draw ---------- */
draw(st){
const R=this.R;if(!R)return;
const ctx=R.ctx,cv=R.cv,W=cv.width,H=cv.height,L=st.L,P=L.pal;
ctx.fillStyle='#0a0f18';ctx.fillRect(0,0,W,H);
const cm=this.camFor(st,W,H),scale=cm.scale;
ctx.save();
ctx.translate(cm.ox,cm.oy);ctx.scale(scale,scale);
if(st.shake>0.05)ctx.translate((Math.random()-0.5)*st.shake*2,(Math.random()-0.5)*st.shake*2);
const SX=x=>this._sx(L,x,W,H),SY=y=>this._sy(L,y,W,H);
// ground
ctx.fillStyle='#0d1420';
ctx.fillRect(-200,-200,W/scale+400,H/scale+400);
// walkable floors
ctx.fillStyle=P.floor;
L.floors.forEach(function(f){
const x=f[0],y=f[1],w=f[2],h=f[3];
ctx.fillRect(SX(x),SY(y),SX(x+w)-SX(x),SY(y+h)-SY(y));
});
ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=0.4;
for(let gx=0;gx<=L.w;gx+=10){ctx.beginPath();ctx.moveTo(SX(gx),SY(0));ctx.lineTo(SX(gx),SY(L.h));ctx.stroke();}
for(let gy=0;gy<=L.h;gy+=10){ctx.beginPath();ctx.moveTo(SX(0),SY(gy));ctx.lineTo(SX(L.w),SY(gy));ctx.stroke();}
// site zones
['A','B'].forEach(function(k){
const s=L.sites[k];
ctx.strokeStyle='rgba(255,150,60,0.3)';ctx.lineWidth=1;
ctx.strokeRect(SX(s[0]-12),SY(s[1]-7),SX(s[0]+12)-SX(s[0]-12),SY(s[1]+7)-SY(s[1]-7));
ctx.fillStyle='rgba(255,170,80,0.22)';
ctx.font='bold 11px system-ui';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(k,SX(s[0]),SY(s[1]));
});
// crates
(L.crates||[]).forEach(function(c){
const cx=c[0],cy=c[1],s=4;
ctx.fillStyle=P.crate;
ctx.fillRect(SX(cx-s/2),SY(cy-s/2),SX(cx+s/2)-SX(cx-s/2),SY(cy+s/2)-SY(cy-s/2));
ctx.strokeStyle='rgba(0,0,0,0.45)';
ctx.strokeRect(SX(cx-s/2),SY(cy-s/2),SX(cx+s/2)-SX(cx-s/2),SY(cy+s/2)-SY(cy-s/2));
});
// fires
st.fires.forEach(function(f){
const flick=0.75+0.25*Math.sin(performance.now()/55+f.x*7);
const x=SX(f.x),y=SY(f.y);
const r=(f.r*flick)/scale*9;
const g=ctx.createRadialGradient(x,y,0,x,y,r);
g.addColorStop(0,'rgba(255,150,40,0.85)');g.addColorStop(1,'rgba(255,60,10,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fill();
});
// smokes
st.smokes.forEach(function(s){
const grow=Math.min(s.life*5,1);
const fade=s.life>0.75?(1-(s.life-0.75)/0.25):1;
const x=SX(s.x),y=SY(s.y),r=s.r/scale*9*Math.max(grow,0.3);
const g=ctx.createRadialGradient(x,y,r*0.25,x,y,r);
g.addColorStop(0,'rgba(185,195,210,'+(0.85*fade)+')');
g.addColorStop(1,'rgba(185,195,210,'+(0.28*fade)+')');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fill();
});
// nades in flight
st.nades.forEach(function(n){
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath();ctx.ellipse(SX(n.x),SY(n.y),1.3,0.7,0,0,7);ctx.fill();
ctx.fillStyle=n.type==='smoke'?'#9aa7b8':n.type==='flash'?'#f5f5da':'#5c8a4a';
ctx.beginPath();ctx.arc(SX(n.x),SY(n.y)-n.z*1.6,1.5,0,7);ctx.fill();
});
// corpses & pools
st.corpses.forEach(function(c){
c.grow=Math.min((c.grow||0)+0.01,1);
ctx.fillStyle='rgba(140,20,25,'+(0.55*c.grow)+')';
ctx.beginPath();ctx.ellipse(SX(c.x),SY(c.y),5*c.grow,3.6*c.grow,c.ang,0,7);ctx.fill();
ctx.save();ctx.translate(SX(c.x),SY(c.y));ctx.rotate(c.ang);
ctx.globalAlpha=0.9;
ctx.fillStyle=c.ct?'#3c5a7d':'#7d6437';
ctx.fillRect(-3.4,-1.6,6.8,3.2);
ctx.beginPath();ctx.arc(3.2,0,1.5,0,7);
ctx.fillStyle=c.ct?'#5b83ad':'#a3834c';ctx.fill();
ctx.restore();ctx.globalAlpha=1;
});
// agents
st.agents.forEach(a=>{
if(a.dead)return;
const x=SX(a.x),y=SY(a.y);
const bob=a.moving?Math.sin(performance.now()/65+a.bob)*0.4:0;
ctx.save();ctx.translate(x,y+bob*0.3);
ctx.fillStyle='rgba(0,0,0,0.38)';
ctx.beginPath();ctx.ellipse(0.7,1.5,3.2,2.1,0,0,7);ctx.fill();
ctx.rotate(a.ang);
ctx.fillStyle='#10151f';
ctx.fillRect(2.1,-0.5,a.role==='AWP'?5.2:3.6,1.05);
ctx.fillStyle=a.isCT?'#4d7fb5':'#c79a4b';
ctx.beginPath();ctx.ellipse(0,0,2.7,2,0,0,7);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.55)';ctx.lineWidth=0.5;ctx.stroke();
ctx.fillStyle=a.isCT?'#8db8e4':'#e8c887';
ctx.beginPath();ctx.arc(0.4,0,1.25,0,7);ctx.fill();
ctx.restore();
if(a.blind>0){
ctx.strokeStyle='rgba(255,255,240,'+Math.min(a.blind,1)+')';ctx.lineWidth=1;
ctx.beginPath();ctx.arc(x,y,4.8,0,7);ctx.stroke();
}
ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(x-6,y-8.4,12,1.9);
ctx.fillStyle=a.hp>60?'#43d17c':a.hp>30?'#e6b23c':'#e05252';
ctx.fillRect(x-6,y-8.4,12*U.clamp(a.hp/100,0,1),1.9);
ctx.font='7px system-ui';ctx.textAlign='center';ctx.textBaseline='top';
ctx.fillStyle='rgba(220,230,245,0.88)';
ctx.fillText(a.nick.slice(0,9),x,y+5.2);
if(!st.planted&&st.bombCarrier===a&&st.plantP>0)VIZ.bar(ctx,x,y-11,st.plantP,'#ffab4d');
if(st.defuser===a&&st.defuseP>0)VIZ.bar(ctx,x,y-11,st.defuseP,'#63a7e6');
if(st.bombCarrier===a&&!st.planted){
ctx.font='bold 7px system-ui';ctx.fillStyle='#ffab4d';
ctx.fillText('C4',x,y-13.8);
}
});
// planted bomb
if(st.planted){
const bx=SX(st.bombPos.x),by=SY(st.bombPos.y);
const iv=st.bombT<10?130:320;
const pulse=0.5+0.5*Math.sin(performance.now()/iv);
ctx.strokeStyle='rgba(255,130,40,'+(0.4+pulse*0.55)+')';ctx.lineWidth=1.3;
ctx.beginPath();ctx.arc(bx,by,5+pulse*4,0,7);ctx.stroke();
ctx.fillStyle='#ff8c28';ctx.fillRect(bx-2.3,by-2.3,4.6,4.6);
if(st.defuseP>0){
ctx.strokeStyle='#63a7e6';ctx.lineWidth=1.2;
ctx.beginPath();ctx.arc(bx,by,8,-Math.PI/2,-Math.PI/2+U.clamp(st.defuseP,0,1)*Math.PI*2);ctx.stroke();
}
}
// transient fx
st.fx.forEach(f=>{
const p=U.clamp((st.t-f.t0)/f.dur,0,1);
if(f.type==='tracer'){
ctx.strokeStyle='rgba(255,235,170,'+(1-p)+')';ctx.lineWidth=0.8;
ctx.beginPath();ctx.moveTo(SX(f.x1),SY(f.y1));ctx.lineTo(SX(f.x2),SY(f.y2));ctx.stroke();
}else if(f.type==='muzzle'){
ctx.fillStyle='rgba(255,230,150,'+(1-p)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1.5-p,0,7);ctx.fill();
}else if(f.type==='bloodHit'){
ctx.fillStyle='rgba(190,40,40,'+(1-p)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1.6+p,0,7);ctx.fill();
}else if(f.type==='spark'){
ctx.fillStyle='rgba(255,220,140,'+(1-p)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1+p*1.6,0,7);ctx.fill();
}else if(f.type==='puff'){
ctx.fillStyle='rgba(180,180,180,'+((1-p)*0.5)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1+p*2.2,0,7);ctx.fill();
}else if(f.type==='boom'){
const r=(3+p*13)/scale*5;
ctx.strokeStyle='rgba(255,180,80,'+(1-p)+')';ctx.lineWidth=1.4;
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),r,0,7);ctx.stroke();
ctx.fillStyle='rgba(255,200,90,'+((1-p)*0.45)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),r*0.6,0,7);ctx.fill();
}else if(f.type==='flashPop'){
ctx.fillStyle='rgba(255,255,255,'+((1-p)*0.92)+')';
ctx.fillRect(0,0,W,H);
}
});
ctx.restore();
/* ===== screen-space overlays ===== */
if(st.pain>0.02){
const g=ctx.createRadialGradient(W/2,H/2,H*0.32,W/2,H/2,H*0.72);
g.addColorStop(0,'rgba(255,30,30,0)');
g.addColorStop(1,'rgba(255,25,25,'+(st.pain*0.36)+')');
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
}
// HUD top bar
ctx.fillStyle='rgba(8,12,20,0.78)';ctx.fillRect(0,0,W,26);
const aA=st.agents.filter(a=>!a.dead&&a.side==='a').length;
const aB=st.agents.filter(a=>!a.dead&&a.side==='b').length;
for(let i=0;i<5;i++){
ctx.fillStyle=i<aA?'#4d7fb5':'rgba(90,100,120,0.25)';
ctx.fillRect(14+i*11,8,8,10);
ctx.fillStyle=i<aB?'#c79a4b':'rgba(90,100,120,0.25)';
ctx.fillRect(W-22-i*11,8,8,10);
}
const shown=st.planted?st.bombT:st.timeLeft;
ctx.fillStyle=st.planted?'#ffab4d':shown<15?'#ff7a5c':'#dfe8f5';
ctx.font='bold 13px ui-monospace,monospace';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(Math.floor(Math.max(shown,0)/60)+':'+String(Math.floor(Math.max(shown,0)%60)).padStart(2,'0'),W/2,13);
// kill feed
ctx.textAlign='right';ctx.textBaseline='middle';
st.feed.forEach((f,i)=>{
const age=st.t-f.t0;
const slide=(1-Math.min(age/0.05,1))*150;
const alpha=age>3.2?Math.max(1-(age-3.2)/0.6,0):1;
const y=40+i*19;
ctx.font='bold 10px system-ui';
const kw=ctx.measureText(f.killer).width;
ctx.font='9px ui-monospace,monospace';
const ww=ctx.measureText(' '+f.wpn+' ').width;
ctx.font='bold 10px system-ui';
const vw=ctx.measureText(f.victim).width;
const bw=kw+ww+vw+18;
ctx.fillStyle='rgba(8,12,20,'+(0.72*alpha)+')';
ctx.beginPath();ctx.roundRect(W-bw-slide,y-8,bw,16,4);ctx.fill();
ctx.globalAlpha=alpha;
ctx.fillStyle=f.kCT?'#8db8e4':'#e8c887';
ctx.fillText(f.killer,W-vw-ww-9-slide,y);
ctx.font='9px ui-monospace,monospace';ctx.fillStyle='rgba(200,210,225,0.85)';
ctx.fillText(' '+f.wpn+' ',W-vw-9-slide,y);
ctx.font='bold 10px system-ui';
ctx.fillStyle=f.vCT?'#8db8e4':'#e8c887';
ctx.fillText(f.victim,W-9-slide,y);
ctx.globalAlpha=1;
});
// callout
if(st.callout!==undefined&&st.callout!==null){
const p=(st.t-st.calloutT)/0.55;
if(p<1){
const pop=p<0.2?(0.2-p)*5:0;
const alpha=p>0.65?Math.max(1-(p-0.65)/0.35,0):1;
ctx.save();ctx.globalAlpha=Math.max(alpha,0);
ctx.translate(W/2,62-pop*6);ctx.scale(1+pop,1+pop);
ctx.font='italic bold 21px system-ui';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.lineWidth=4;ctx.strokeStyle='rgba(5,8,14,0.85)';
ctx.strokeText(st.callout.txt,0,0);
ctx.fillStyle=st.callout.color||'#fff';
ctx.fillText(st.callout.txt,0,0);
ctx.restore();
}else st.callout=null;
}
// end banner
if(st.ended){
const reasonTxt={elim:'ELIMINATION',bomb:'THE BOMB DETONATED 💥',
defuse:'BOMB DEFUSED',time:'TIME UP — DEFENSE HOLDS'}[st.reason]||'';
const wt=st.cfg.tags[st.winner]||'?';
ctx.fillStyle='rgba(6,10,18,0.74)';ctx.fillRect(0,H/2-34,W,68);
ctx.fillStyle=st.cfg.colors[st.winner]||'#fff';ctx.font='bold 20px system-ui';
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(wt+' WIN THE ROUND',W/2,H/2-9);
ctx.fillStyle='rgba(255,255,255,0.8)';ctx.font='12px system-ui';
ctx.fillText(reasonTxt+' · '+st.cfg.scoreAfter[0]+' : '+st.cfg.scoreAfter[1],W/2,H/2+14);
}
},
bar(ctx,x,y,p,color){
ctx.fillStyle='rgba(0,0,0,0.6)';ctx.fillRect(x-8,y-1.5,16,2.6);
ctx.fillStyle=color;ctx.fillRect(x-8,y-1.5,16*U.clamp(p,0,1),2.6);
},
/* static pre-round preview */
preview(map,roster,ctSide){
if(!this.ok||this.R)return;
const cv=document.getElementById('simCanvas');if(!cv)return;
const fake={map:map,ctSide:ctSide,lineups:{a:[],b:[]},players:{},skills:{},
buys:{a:'full',b:'full'},tactic:{a:'default',b:'default'},
tags:{},colors:{},userSide:'a',scoreAfter:[0,0]};
roster.forEach(r=>{
fake.players[r.pid]={nick:r.nick,role:r.role};
fake.skills[r.pid]={aim:60,spd:60,clutch:60};
if(r.side===ctSide)fake.lineups.a.push(r.pid);
else fake.lineups.b.push(r.pid);
});
try{
const st=LIVE.makeRound(fake);
const keep=this.R;this.R={st:st,cv:cv,ctx:cv.getContext('2d')};
this.draw(st);this.R=keep;
}catch(e){}
},
};
if(typeof window!=='undefined')VIZ.init();