- 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)
709 lines
26 KiB
JavaScript
709 lines
26 KiB
JavaScript
/* ============================================================
|
|
LIVE — real-time tactical micro-simulation (top-down CS)
|
|
A* pathfinding on real geometry, raycast bullets with spread/
|
|
falloff/armor/headshots, utility that matters (smoke blocks
|
|
LOS, flash blinds, HE/molly damage), plant/defuse/timer.
|
|
The round outcome comes OUT of this fight and is fed back
|
|
into the career engine by SimSession.finishLiveRound().
|
|
============================================================ */
|
|
'use strict';
|
|
|
|
const LIVE=(()=>{
|
|
function VZ(){return (typeof window!=='undefined')?window.VIZ:null;}
|
|
|
|
/* ---------------- maps ---------------- */
|
|
const MAPS={
|
|
'Dust II':{
|
|
pal:{floor:'#182234',wall:'#2c3c5a',crate:'#3b4f73'},
|
|
/* lanes separated by real walls: long | mid | tunnels,
|
|
joined only at doors / catwalk-stairs / site mouths */
|
|
floors:[[12,66,14,10], // T spawn
|
|
[26,60,32,8], // outside long (low)
|
|
[58,58,4,6], // doors
|
|
[62,58,36,8], // along long bottom
|
|
[96,16,10,42], // long A (vertical)
|
|
[96,6,26,20], // A site
|
|
[56,30,40,6], // catwalk (mid->short)
|
|
[88,25,8,5], // short stairs
|
|
[46,36,9,30], // mid
|
|
[40,62,8,6], // tspawn->mid connector
|
|
[55,18,41,7], // top-mid (CT side)
|
|
[20,46,18,8], // lower tunnel
|
|
[16,22,10,24], // upper tunnel
|
|
[24,54,8,6], // tspawn->tunnels
|
|
[6,6,28,20], // B site
|
|
[30,6,72,8], // CT rotate corridor
|
|
[110,6,14,14]], // CT spawn
|
|
crates:[[101,10],[106,17],[113,12],[119,21],[13,11],[21,16],[27,9],
|
|
[51,45],[86,32],[100,44],[120,32]],
|
|
tSpawn:[18,71],ctSpawn:[117,11],
|
|
sites:{A:[108,15],B:[18,15]},
|
|
chokes:{A:[[99,26]],B:[[20,27]]},
|
|
holds:{A:[[104,10,'r'],[114,21,'d'],[98,11,'l'],[103,23,'dr']],
|
|
B:[[12,9,'l'],[24,19,'d'],[8,20,'dl'],[30,10,'ld']],
|
|
mid:[[60,22,'d'],[49,40,'u']]},
|
|
flashes:{A:[104,18],B:[17,16]},
|
|
},
|
|
'Mirage':{
|
|
pal:{floor:'#1b2030',wall:'#37304a',crate:'#4a4266'},
|
|
floors:[[10,64,16,12],[28,58,30,12],[58,54,10,10],[68,56,34,10],
|
|
[100,16,14,42],[96,6,26,20],[52,26,46,8],[44,30,12,36],
|
|
[58,16,40,10],[16,42,20,10],[14,18,12,26],[6,6,28,20],
|
|
[32,6,68,8],[42,62,10,6],[110,6,14,14]],
|
|
crates:[[102,11],[107,17],[112,13],[99,21],[13,11],[21,15],[27,10],
|
|
[60,30],[47,50],[104,42],[86,60]],
|
|
tSpawn:[17,71],ctSpawn:[117,11],
|
|
sites:{A:[109,15],B:[19,15]},
|
|
chokes:{A:[[99,27]],B:[[20,27]]},
|
|
holds:{A:[[107,10,'r'],[114,21,'d'],[97,13,'l'],[102,23,'dr']],
|
|
B:[[11,9,'l'],[25,17,'d'],[7,19,'dl'],[31,11,'ld']],
|
|
mid:[[63,21,'d'],[49,33,'u']]},
|
|
flashes:{A:[104,17],B:[16,15]},
|
|
},
|
|
'Inferno':{
|
|
pal:{floor:'#20191b',wall:'#4a3038',crate:'#6b4630'},
|
|
floors:[[10,62,16,12],[28,58,26,10],[54,52,14,10],[68,50,16,10],
|
|
[84,48,20,12],[96,44,24,16],[88,10,14,36],[90,6,28,18],
|
|
[46,30,12,26],[58,24,34,8],[16,40,14,22],[14,16,12,26],
|
|
[6,6,26,20],[32,6,60,8],[110,24,12,22],[40,60,10,6]],
|
|
crates:[[95,11],[101,17],[107,12],[93,20],[12,11],[20,15],[26,10],
|
|
[52,33],[62,27],[100,51],[116,52]],
|
|
tSpawn:[17,69],ctSpawn:[116,11],
|
|
sites:{A:[104,14],B:[106,53]},
|
|
chokes:{A:[[92,23]],B:[[92,53]]},
|
|
holds:{A:[[101,9,'r'],[111,20,'d'],[92,12,'l'],[98,20,'dr']],
|
|
B:[[112,47,'d'],[99,57,'l'],[114,57,'dl'],[96,46,'lu']],
|
|
mid:[[62,27,'d'],[50,34,'u']]},
|
|
flashes:{A:[98,16],B:[100,55]},
|
|
},
|
|
'Nuke':{
|
|
pal:{floor:'#161e2c',wall:'#33415c',crate:'#465a80'},
|
|
floors:[[12,34,18,12],[30,30,18,20],[48,26,44,28],[92,30,26,20],
|
|
[56,10,24,16],[56,54,24,16],[100,10,20,16],[100,54,20,16],
|
|
[30,12,26,10],[30,58,26,10],[12,12,18,14],[12,54,18,14]],
|
|
crates:[[62,14],[71,14],[62,60],[71,60],[52,33],[84,45],[104,16],
|
|
[110,61],[36,35]],
|
|
tSpawn:[20,40],ctSpawn:[112,40],
|
|
sites:{A:[110,17],B:[110,62]},
|
|
chokes:{A:[[96,18]],B:[[96,62]]},
|
|
holds:{A:[[106,12,'r'],[116,22,'d'],[98,14,'l']],
|
|
B:[[106,58,'r'],[116,52,'u'],[98,65,'l']],
|
|
mid:[[88,38,'l'],[52,40,'r']]},
|
|
flashes:{A:[106,18],B:[106,62]},
|
|
},
|
|
'Ancient':{
|
|
pal:{floor:'#141d22',wall:'#2c4a44',crate:'#3d6459'},
|
|
floors:[[12,36,16,10],[28,30,18,22],[46,26,40,28],[86,30,24,20],
|
|
[58,8,22,18],[58,54,22,18],[98,8,22,18],[98,54,22,18],
|
|
[28,10,28,10],[28,60,28,10]],
|
|
crates:[[64,12],[73,12],[64,60],[73,60],[50,31],[82,45],[102,12],
|
|
[108,62],[34,35]],
|
|
tSpawn:[19,41],ctSpawn:[111,41],
|
|
sites:{A:[109,16],B:[109,62]},
|
|
chokes:{A:[[94,17]],B:[[94,62]]},
|
|
holds:{A:[[105,11,'r'],[115,21,'d'],[97,13,'l']],
|
|
B:[[105,58,'r'],[115,51,'u'],[97,66,'l']],
|
|
mid:[[82,39,'l'],[50,40,'r']]},
|
|
flashes:{A:[104,17],B:[104,61]},
|
|
},
|
|
};
|
|
MAPS['Anubis']=JSON.parse(JSON.stringify(MAPS['Mirage']));
|
|
MAPS['Train']=JSON.parse(JSON.stringify(MAPS['Dust II']));
|
|
MAPS['Overpass']=JSON.parse(JSON.stringify(MAPS['Ancient']));
|
|
MAPS['Vertigo']=JSON.parse(JSON.stringify(MAPS['Nuke']));
|
|
|
|
/* ---------------- grid / nav ---------------- */
|
|
const GW=130,GH=80;
|
|
function buildGrid(L){
|
|
const g=new Uint8Array(GW*GH);
|
|
L.floors.forEach(([x,y,w,h])=>{
|
|
for(let yy=Math.max(0,y|0);yy<Math.min(GH,Math.ceil(y+h));yy++)
|
|
for(let xx=Math.max(0,x|0);xx<Math.min(GW,Math.ceil(x+w));xx++)g[yy*GW+xx]=1;
|
|
});
|
|
(L.crates||[]).forEach(([cx,cy])=>{
|
|
for(let yy=(cy-2)|0;yy<=((cy+2)|0);yy++)
|
|
for(let xx=(cx-2)|0;xx<=((cx+2)|0);xx++)
|
|
if(xx>=0&&yy>=0&&xx<GW&&yy<GH)g[yy*GW+xx]=0;
|
|
});
|
|
ensureConnected(L,g);
|
|
return g;
|
|
}
|
|
function walkable(g,x,y){x|=0;y|=0;return x>=0&&y>=0&&x<GW&&y<GH&&g[y*GW+x]===1;}
|
|
function snap(g,x,y){
|
|
if(walkable(g,x,y))return{x:x+0.5,y:y+0.5};
|
|
for(let r=1;r<=7;r++){
|
|
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
|
|
if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue;
|
|
const nx=(x|0)+dx,ny=(y|0)+dy;
|
|
if(walkable(g,nx,ny))return{x:nx+0.5,y:ny+0.5};
|
|
}
|
|
}
|
|
return{x:x+0.5,y:y+0.5};
|
|
}
|
|
function astar(g,sx,sy,tx,ty){
|
|
sx|=0;sy|=0;tx|=0;ty|=0;
|
|
if(!walkable(g,sx,sy)||!walkable(g,tx,ty))return null;
|
|
if(sx===tx&&sy===ty)return[{x:tx+0.5,y:ty+0.5}];
|
|
const W=GW;
|
|
const open=[{i:sy*W+sx,f:0,g:0}];
|
|
const gs=new Float32Array(W*GH).fill(Infinity);
|
|
const came=new Int32Array(W*GH).fill(-1);
|
|
const closed=new Uint8Array(W*GH);
|
|
gs[sy*W+sx]=0;
|
|
const H=i=>Math.abs(i%W-tx)+Math.abs(((i/W)|0)-ty);
|
|
let guard=0;
|
|
while(open.length&&guard++<9000){
|
|
let bi=0;
|
|
for(let i=1;i<open.length;i++)if(open[i].f<open[bi].f)bi=i;
|
|
const cur=open.splice(bi,1)[0],ci=cur.i;
|
|
if(closed[ci])continue;closed[ci]=1;
|
|
const cx=ci%W,cy=(ci/W)|0;
|
|
if(cx===tx&&cy===ty){
|
|
const path=[];let i=ci;
|
|
while(i!==-1&&i!==sy*W+sx){path.push({x:i%W+0.5,y:((i/W)|0)+0.5});i=came[i];}
|
|
path.push({x:sx+0.5,y:sy+0.5});path.reverse();
|
|
// string-pull lite: drop intermediate nodes with clear LOS
|
|
const sm=[path[0]];let j=0;
|
|
for(let k=2;k<path.length;k++){
|
|
if(!losClear(g,path[j].x,path[j].y,path[k].x,path[k].y)){sm.push(path[k-1]);j=k-1;}
|
|
}
|
|
sm.push(path[path.length-1]);
|
|
return sm;
|
|
}
|
|
for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){
|
|
const nx=cx+dx,ny=cy+dy;
|
|
if(!walkable(g,nx,ny))continue;
|
|
const ni=ny*W+nx,ng=cur.g+1;
|
|
if(ng<gs[ni]){gs[ni]=ng;came[ni]=ci;open.push({i:ni,g:ng,f:ng+H(ni)});}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function losClear(g,x0,y0,x1,y1){
|
|
const dx=x1-x0,dy=y1-y0,d=Math.hypot(dx,dy);
|
|
if(d<0.01)return true;
|
|
return !raycast(g,x0,y0,dx/d,dy/d,d,null).hit;
|
|
}
|
|
function raycast(g,x0,y0,dx,dy,maxD,smokes){
|
|
let x=x0,y=y0;const stp=0.45;
|
|
for(let d=0;d<maxD;d+=stp){
|
|
x+=dx*stp;y+=dy*stp;
|
|
if(!walkable(g,x,y))return{x:x-dx*stp,y:y-dy*stp,hit:true,d};
|
|
if(smokes)for(let i=0;i<smokes.length;i++){
|
|
const s=smokes[i];
|
|
if(s.life>0.12&&s.life<0.9){
|
|
const ex=x-s.x,ey=y-s.y;
|
|
if(ex*ex+ey*ey<s.r*s.r)return{x,y,hit:false,smoke:true,d};
|
|
}
|
|
}
|
|
}
|
|
return{x,y,hit:false,d:maxD};
|
|
}
|
|
|
|
/* guarantee every spawn can reach both sites: carve corridors if needed */
|
|
function ensureConnected(L,g){
|
|
[L.tSpawn,L.ctSpawn].forEach(sp=>{
|
|
if(!walkable(g,sp[0],sp[1])){const s=snap(g,sp[0],sp[1]);sp[0]=s.x;sp[1]=s.y;}
|
|
const targets=[L.sites.A,L.sites.B]
|
|
.concat(L.chokes.A||[],L.chokes.B||[]);
|
|
targets.forEach(tg=>{
|
|
if(walkable(g,tg[0],tg[1])&&astar(g,sp[0],sp[1],tg[0],tg[1]))return;
|
|
const put=(x,y)=>{
|
|
[[0,0],[1,0],[-1,0],[0,1],[0,-1]].forEach(([dx,dy])=>{
|
|
const nx=x+dx,ny=y+dy;
|
|
if(nx>=1&&ny>=1&&nx<GW-1&&ny<GH-1)g[ny*GW+nx]=1;
|
|
});
|
|
};
|
|
let x=sp[0]|0,y=sp[1]|0;
|
|
const tx=tg[0]|0,ty=tg[1]|0;
|
|
while(x!==tx){x+=tx>x?1:-1;put(x,y);}
|
|
while(y!==ty){y+=ty>y?1:-1;put(x,y);}
|
|
tg[0]=tx+0.5;tg[1]=ty+0.5;
|
|
L.carved=L.carved||[];
|
|
L.carved.push([Math.min(sp[0],tx)-1,Math.min(sp[1],ty)-1,
|
|
Math.abs(tx-sp[0])+3,Math.abs(ty-sp[1])+3]);
|
|
});
|
|
});
|
|
}
|
|
/* ---------------- weapons ---------------- */
|
|
const WEAPONS={
|
|
awp:{name:'AWP ⭐',dmg:115,rpm:45,rng:60,spread:0.015,move:0.7,big:true},
|
|
rifle:{name:'Rifle',dmg:37,rpm:600,rng:38,spread:0.042,move:0.9},
|
|
smg:{name:'SMG',dmg:28,rpm:780,rng:24,spread:0.06,move:1.0},
|
|
pistol:{name:'Pistol',dmg:32,rpm:400,rng:20,spread:0.05,move:1.0},
|
|
};
|
|
|
|
/* ---------------- round construction ---------------- */
|
|
function makeRound(cfg){
|
|
const L=MAPS[cfg.map]||MAPS['Mirage'];
|
|
L.w=L.w||130;L.h=L.h||80;
|
|
const g=buildGrid(L);
|
|
const tKey=cfg.ctSide==='a'?'b':'a';
|
|
const mk=(pid,side)=>{
|
|
const p=cfg.players[pid],sk=cfg.skills[pid];
|
|
const isCT=side===cfg.ctSide;
|
|
const sp=isCT?L.ctSpawn:L.tSpawn;
|
|
const p0=snap(g,sp[0]+(Math.random()-0.5)*6,sp[1]+(Math.random()-0.5)*6);
|
|
return{pid,nick:p.nick,role:p.role,side,isCT,
|
|
x:p0.x,y:p0.y,
|
|
hp:100,armor:cfg.buys[side]==='full'?100:cfg.buys[side]==='force'?60:0,
|
|
dead:false,ang:isCT?-Math.PI/2:Math.PI/2,
|
|
spd:(0.82+sk.spd*0.0035),
|
|
react:U.clamp(0.46-sk.aim*0.0026-sk.clutch*0.0007,0.13,0.5)*(cfg.timeoutSide===side?0.82:1),
|
|
acc:U.clamp(sk.aim/100,0.3,1),
|
|
weapon:(cfg.buys[side]==='full')
|
|
?(p.role==='AWP'?WEAPONS.awp:WEAPONS.rifle)
|
|
:(cfg.buys[side]==='force'?WEAPONS.smg:WEAPONS.pistol),
|
|
cool:0,blind:0,target:null,lastSeenT:-9,
|
|
path:null,pathI:0,goal:null,fake:false,alertT:-9,
|
|
holdNear:null,kills:0,dmg:0,bob:Math.random()*6.28,moving:false,
|
|
reactT:null};
|
|
};
|
|
const agents=[];
|
|
cfg.lineups.a.forEach(pid=>agents.push(mk(pid,'a')));
|
|
cfg.lineups.b.forEach(pid=>agents.push(mk(pid,'b')));
|
|
|
|
// CT setup: split holds (2-2-1 default)
|
|
const cts=agents.filter(a=>a.isCT);
|
|
const split=Math.random()<0.75?[2,2,1]:Math.random()<0.5?[3,1,1]:[1,3,1];
|
|
let ci=0;
|
|
['A','B','mid'].forEach((setName,si)=>{
|
|
const hs=L.holds[setName];
|
|
for(let i=0;i<split[si]&&ci<cts.length;i++,ci++){
|
|
const a=cts[ci],h=hs[(ci+i)%hs.length];
|
|
const hp=snap(g,h[0]+(Math.random()-0.5)*2,h[1]+(Math.random()-0.5)*2);
|
|
a.x=hp.x;a.y=hp.y;
|
|
a.hold=h;a.task='hold';
|
|
const fa=h[2];
|
|
a.ang=fa==='r'?0:fa==='l'?Math.PI:fa==='u'?-Math.PI/2:fa==='d'?Math.PI/2:
|
|
fa==='dr'?Math.PI/4:fa==='dl'?Math.PI*0.75:fa==='lu'?-Math.PI*0.75:-Math.PI/4;
|
|
}
|
|
});
|
|
|
|
// attacker plan
|
|
const atkTac=cfg.tactic[tKey];
|
|
const site=Math.random()<0.5?'A':'B';
|
|
const atks=agents.filter(a=>!a.isCT);
|
|
const nFake=atkTac==='fake'?2:0;
|
|
atks.forEach((a,i)=>{
|
|
a.fake=i<nFake;
|
|
const tgt=a.fake?L.sites[site==='A'?'B':'A']:L.sites[site];
|
|
const gp=snap(g,tgt[0],tgt[1]);
|
|
a.goal={x:gp.x,y:gp.y};
|
|
});
|
|
|
|
const utilPlan=[];
|
|
if(atkTac==='fake'){
|
|
const o=site==='A'?'B':'A';
|
|
L.chokes[o].forEach(([x,y])=>utilPlan.push({t:7,type:'smoke',x,y}));
|
|
}else{
|
|
L.chokes[site].forEach(([x,y],i)=>utilPlan.push({t:5+i*1.6,type:'smoke',x,y}));
|
|
const fl=L.flashes[site];
|
|
utilPlan.push({t:8,type:'flash',x:fl[0],y:fl[1]});
|
|
}
|
|
|
|
return {cfg,L,g,agents,site,tKey,t:0,timeLeft:115,
|
|
planted:false,plantP:0,bombPos:null,bombT:0,
|
|
bombCarrier:atks[Math.floor(Math.random()*atks.length)],
|
|
defuseP:0,defuser:null,
|
|
smokes:[],fires:[],nades:[],fx:[],corpses:[],feed:[],
|
|
callout:null,calloutT:-9,
|
|
winner:null,reason:null,killsLog:[],
|
|
pain:0,shake:0,ended:false,endT:0,
|
|
firstBlood:false,clutchFlag:false,
|
|
utilPlan,utilDone:new Set(),lastFlashAt:-9};
|
|
}
|
|
|
|
/* ---------------- utility ---------------- */
|
|
function throwNade(st,type,x,y){st.nades.push({type,x,y,fuse:type==='flash'?0.7:type==='he'?1.0:1.1,total:1.1,progress:0,z:0});}
|
|
function detonate(st,n){
|
|
if(n.type==='smoke'){st.smokes.push({x:n.x,y:n.y,r:12.5,life:0});}
|
|
else if(n.type==='flash'){
|
|
st.fx.push({type:'flashPop',t0:st.t,dur:0.09});
|
|
st.agents.forEach(a=>{
|
|
if(a.dead)return;
|
|
const d=Math.hypot(a.x-n.x,a.y-n.y);
|
|
if(d<16&&!raycast(st.g,n.x,n.y,(a.x-n.x)/d,(a.y-n.y)/d,d,null).hit)
|
|
a.blind=Math.max(a.blind,U.clamp(2.5-d*0.1,0.4,2.5));
|
|
});
|
|
const vz=VZ();if(vz)vz.sFlash();
|
|
}else if(n.type==='he'){
|
|
st.fx.push({type:'boom',x:n.x,y:n.y,t0:st.t,dur:0.35});
|
|
st.shake=Math.min(st.shake+1.7,3);
|
|
const vz=VZ();if(vz)vz.sBoom();
|
|
st.agents.forEach(a=>{
|
|
if(a.dead)return;
|
|
const d=Math.hypot(a.x-n.x,a.y-n.y);
|
|
if(d<11)a.hp-=Math.max(0,55*(1-d/11));
|
|
});
|
|
}
|
|
}
|
|
/* ---------------- helpers ---------------- */
|
|
function visAnyEnemy(st,a){
|
|
for(const e of st.agents){
|
|
if(e.dead||e.isCT===a.isCT)continue;
|
|
if(los(st.g,a,e,st.smokes))return true;
|
|
}
|
|
return false;
|
|
}
|
|
function los(g,a,b,smokes){
|
|
const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy);
|
|
if(d<0.01)return true;
|
|
const r=raycast(g,a.x,a.y,dx/d,dy/d,d,smokes);
|
|
return !r.hit&&!r.smoke;
|
|
}
|
|
function hotSite(st){
|
|
let ht=-9,hx=0,hy=0;
|
|
const A=st.L.sites.A,B=st.L.sites.B;
|
|
st.agents.forEach(m=>{
|
|
if(m.lastSeenT>ht){ht=m.lastSeenT;hx=m.seenX;hy=m.seenY;}
|
|
});
|
|
if(ht<=0)return null;
|
|
// contact older than 25s stops drawing rotations
|
|
if(st.t-ht>25)return null;
|
|
return Math.hypot(hx-A[0],hy-A[1])<Math.hypot(hx-B[0],hy-B[1])?'A':'B';
|
|
}
|
|
function holdSideOf(st,a){
|
|
const A=st.L.sites.A,B=st.L.sites.B;
|
|
return Math.hypot(a.x-A[0],a.y-A[1])<Math.hypot(a.x-B[0],a.y-B[1])?'A':'B';
|
|
}
|
|
|
|
/* ---------------- movement ---------------- */
|
|
function moveAlong(st,a,to,dt,noSmooth){
|
|
const g=st.g;
|
|
let to2=to;
|
|
if(a.via){
|
|
if(Math.hypot(a.via.x-a.x,a.via.y-a.y)<1.6)a.via=null;
|
|
else to2=a.via;
|
|
}
|
|
const last=a.path&&a.path[a.path.length-1];
|
|
const needNew=!a.path||a.pathI>=a.path.length||
|
|
Math.hypot(last.x-to2.x,last.y-to2.y)>2.5;
|
|
if(needNew){
|
|
a.path=astar(g,a.x,a.y,to2.x,to2.y)||[{x:to2.x,y:to2.y}];
|
|
a.pathI=0;
|
|
}
|
|
let tgt=a.path[Math.min(a.pathI,a.path.length-1)];
|
|
if(Math.hypot(tgt.x-a.x,tgt.y-a.y)<0.9&&a.pathI<a.path.length-1){a.pathI++;tgt=a.path[a.pathI];}
|
|
const dx=tgt.x-a.x,dy=tgt.y-a.y,d=Math.hypot(dx,dy);
|
|
if(d>0.12){
|
|
const sp=a.spd*3*dt;
|
|
const nx=a.x+dx/d*sp,ny=a.y+dy/d*sp;
|
|
let moved=false;
|
|
if(walkable(g,nx,ny)){a.x=nx;a.y=ny;moved=true;}
|
|
else if(walkable(g,nx,a.y)){a.x=nx;moved=true;} // slide X
|
|
else if(walkable(g,a.x,ny)){a.y=ny;moved=true;} // slide Y
|
|
if(moved){a._stuck=0;a.ang=Math.atan2(dy,dx);a.moving=true;}
|
|
else{
|
|
a.moving=false;
|
|
a._stuck=(a._stuck||0)+1;
|
|
if(a._stuck>5){
|
|
a._stuck=0;a.path=null;a.via=null;
|
|
for(let rr=3;rr<=10;rr+=3){
|
|
const s=snap(g,a.x+(Math.random()-0.5)*rr*2,a.y+(Math.random()-0.5)*rr*2);
|
|
if(Math.hypot(s.x-a.x,s.y-a.y)>2){a.via={x:s.x,y:s.y};break;}
|
|
}
|
|
}
|
|
}
|
|
}else{a.moving=false;a._stuck=0;}
|
|
}
|
|
|
|
/* ---------------- combat ---------------- */
|
|
function shoot(st,a,vis){
|
|
a.shots=(a.shots||0)+1;
|
|
const d=Math.hypot(vis.x-a.x,vis.y-a.y);
|
|
const w=a.weapon;
|
|
const spread=w.spread*(1+(a.moving?0.85:0))*(1.35-Math.min(d/w.rng,1)*0.6)/(0.45+a.acc*0.8);
|
|
const ang=a.ang+(Math.random()-0.5)*spread*5.5;
|
|
const maxD=Math.min(w.rng*1.7,d+15);
|
|
const hit=raycast(st.g,a.x,a.y,Math.cos(ang),Math.sin(ang),maxD,st.smokes);
|
|
let victim=null,hitAt={x:hit.x,y:hit.y};
|
|
if(!hit.hit&&!hit.smoke){
|
|
let bt=1e9;
|
|
for(const e of st.agents){
|
|
if(e.dead||e.isCT===a.isCT)continue;
|
|
const rx=e.x-a.x,ry=e.y-a.y;
|
|
const tt=rx*Math.cos(ang)+ry*Math.sin(ang);
|
|
if(tt<0||tt>hit.d)continue;
|
|
const px=a.x+Math.cos(ang)*tt,py=a.y+Math.sin(ang)*tt;
|
|
const dd=Math.hypot(px-e.x,py-e.y);
|
|
if(dd<1.25&&tt<bt){bt=tt;victim=e;hitAt={x:px,y:py};}
|
|
}
|
|
}
|
|
st.fx.push({type:'tracer',x1:a.x+Math.cos(ang)*2,y1:a.y+Math.sin(ang)*2,x2:hitAt.x,y2:hitAt.y,t0:st.t,dur:0.05});
|
|
st.fx.push({type:'muzzle',x:a.x+Math.cos(ang)*2.2,y:a.y+Math.sin(ang)*2.2,t0:st.t,dur:0.04,ang});
|
|
if(victim){
|
|
const fall=U.clamp(1-d/(w.rng*2.4),0.55,1);
|
|
let dmg=w.dmg*fall;
|
|
const hs=Math.random()<0.15+a.acc*0.12;
|
|
if(hs)dmg*=2.4;
|
|
if(victim.armor>0){victim.armor=Math.max(0,victim.armor-dmg*0.5);dmg*=0.68;}
|
|
dmg=Math.min(dmg,victim.hp);
|
|
victim.hp-=dmg;a.dmg+=dmg;
|
|
st.fx.push({type:'bloodHit',x:victim.x,y:victim.y,t0:st.t,dur:0.1});
|
|
const vz=VZ();if(vz)vz.sHit();
|
|
if(victim.hp<=0)kill(st,victim,a,w.name+(hs?' ⌖':''));
|
|
}else{
|
|
st.fx.push({type:hit.hit?'spark':'puff',x:hitAt.x,y:hitAt.y,t0:st.t,dur:0.13});
|
|
}
|
|
const vz=VZ();if(vz)vz.sShot(w.big);
|
|
}
|
|
function kill(st,v,killer,wpn){
|
|
if(v.dead)return;
|
|
v.dead=true;v.hp=0;
|
|
st.corpses.push({x:v.x,y:v.y,ang:v.ang,ct:v.isCT,grow:0});
|
|
st.shake=Math.min(st.shake+0.9,2.4);
|
|
const vz=VZ();if(vz)vz.sDeath();
|
|
const wct=killer?killer.isCT:!v.isCT;
|
|
if(killer){
|
|
killer.kills++;
|
|
st.killsLog.push({k:killer.pid,v:v.pid,wpn:wpn||''});
|
|
st.feed.unshift({t0:st.t,killer:killer.nick,kCT:killer.isCT,wpn:wpn||'',victim:v.nick,vCT:v.isCT});
|
|
if(st.feed.length>5)st.feed.pop();
|
|
}
|
|
if(v.side===st.cfg.userSide)st.pain=1;
|
|
if(!st.firstBlood){st.firstBlood=true;callout(st,'FIRST BLOOD!','#ff5c5c');}
|
|
const aliveCT=st.agents.filter(x=>!x.dead&&x.isCT).length;
|
|
const aliveT=st.agents.filter(x=>!x.dead&&!x.isCT).length;
|
|
if(aliveCT===1&&aliveT>=2&&!st.clutchFlag&&wct){st.clutchFlag=true;callout(st,'🧊 CLUTCH TIME!','#ffd34d');}
|
|
if(aliveT===1&&aliveCT>=2&&!st.clutchFlag&&!wct){st.clutchFlag=true;callout(st,'🧊 CLUTCH TIME!','#ffd34d');}
|
|
const tKey=st.cfg.ctSide==='a'?'b':'a';
|
|
if(aliveCT===0){
|
|
if(st.planted)endRound(st,tKey,'bomb');
|
|
else endRound(st,tKey,'elim');
|
|
}else if(aliveT===0&&!st.planted){
|
|
endRound(st,st.cfg.ctSide,'elim');
|
|
}
|
|
if(v===st.defuser){st.defuser=null;st.defuseP=0;}
|
|
if(v===st.bombCarrier&&!st.planted){
|
|
const ts=st.agents.filter(x=>!x.dead&&!x.isCT);
|
|
if(ts.length)st.bombCarrier=ts[0];
|
|
}
|
|
}
|
|
function callout(st,txt,color){st.callout={txt,color};st.calloutT=st.t;}
|
|
|
|
/* ---------------- AI brains ---------------- */
|
|
function tBrain(st,a,dt){
|
|
// late-round urgency: commit everyone left to the chosen site
|
|
if(!st.planted&&!a.fake&&st.timeLeft<78&&!a._rushed){
|
|
a._rushed=true;
|
|
const tg=st.L.sites[st.site];const gp=snap(st.g,tg[0],tg[1]);
|
|
a.goal={x:gp.x,y:gp.y};a.path=null;a.via=null;
|
|
}
|
|
if(st.planted){
|
|
// post-plant: watch entrances near bomb
|
|
const entr=st.L.chokes[st.site][0];
|
|
a.moving=false;
|
|
if(Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y)>9)moveAlong(st,a,{x:st.bombPos.x,y:st.bombPos.y},dt);
|
|
else a.ang=Math.atan2(entr[1]-a.y,entr[0]-a.x)+(a.bob%1-0.5)*0.5;
|
|
return;
|
|
}
|
|
const siteC=st.L.sites[st.site];
|
|
if(a.fake){
|
|
const o=st.site==='A'?'B':'A';
|
|
const ch=st.L.chokes[o][0];
|
|
if(Math.hypot(a.x-ch[0],a.y-ch[1])>6)moveAlong(st,a,{x:ch[0],y:ch[1]},dt);
|
|
else if(st.t>16){a.fake=false;const tg=st.L.sites[st.site];a.goal={x:tg[0],y:tg[1]};}
|
|
return;
|
|
}
|
|
const atSite=Math.hypot(a.x-siteC[0],a.y-siteC[1])<9.5;
|
|
if(atSite){
|
|
let blocker=null,bd=1e9;
|
|
st.agents.forEach(e=>{
|
|
if(e.dead||!e.isCT)return;
|
|
const dd=Math.hypot(e.x-a.x,e.y-a.y);
|
|
if(dd<bd&&(dd<8||los(st.g,a,e,st.smokes))){bd=dd;blocker=e;}
|
|
});
|
|
const desperate=st.timeLeft<30&&bd>4.5;
|
|
const enemiesNear=blocker&&!desperate;
|
|
if(st.bombCarrier===a&&!enemiesNear){
|
|
a.moving=false;
|
|
st.plantP+=dt/3.2;
|
|
if(Math.floor(st.plantP*8)!==a._lastBeep){a._lastBeep=Math.floor(st.plantP*8);}
|
|
if(st.plantP>=1){
|
|
st.planted=true;st.bombPos={x:a.x,y:a.y};st.bombT=40;st.plantP=1;
|
|
callout(st,'💣 BOMB PLANTED','#ffab4d');
|
|
const vz=VZ();if(vz)vz.sPlant();
|
|
}
|
|
return;
|
|
}
|
|
if(blocker&&st.timeLeft<70){
|
|
// entry fight: collapse onto the defender
|
|
moveAlong(st,a,{x:blocker.x,y:blocker.y},dt);
|
|
return;
|
|
}
|
|
// support: crowd around site watching chokes
|
|
a.moving=false;
|
|
a.ang=Math.atan2(st.L.chokes[st.site][0][1]-a.y,st.L.chokes[st.site][0][0]-a.x)+(a.bob%1-0.5)*0.6;
|
|
return;
|
|
}
|
|
moveAlong(st,a,a.goal,dt);
|
|
}
|
|
function ctBrain(st,a,dt){
|
|
if(st.planted){
|
|
if(st.defuser&&st.defuser!==a){}
|
|
const d=Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y);
|
|
if(d>2.3)moveAlong(st,a,st.bombPos,dt);
|
|
else{
|
|
a.moving=false;
|
|
if(visAnyEnemy(st,a))return; // cover first
|
|
}
|
|
return;
|
|
}
|
|
const hot=hotSite(st);
|
|
const mine=a.holdNear||(a.holdNear=holdSideOf(st,a));
|
|
if(hot&&hot!==mine){
|
|
moveAlong(st,a,{x:st.L.sites[hot][0],y:st.L.sites[hot][1]},dt);
|
|
return;
|
|
}
|
|
if(a.hold&&st.planted===false&&st.timeLeft<58){
|
|
// aggressive CTs collapse toward the hotter choke to break the stalemate
|
|
const target=(hot||st.siteGuess||(st.siteGuess=Math.random()<0.5?'A':'B'));
|
|
const ch=st.L.chokes[target][0];
|
|
if(Math.hypot(a.x-ch[0],a.y-ch[1])>6){moveAlong(st,a,{x:ch[0],y:ch[1]},dt);return;}
|
|
}
|
|
if(a.hold){
|
|
a.moving=false;
|
|
const fa=a.hold[2];
|
|
const base=fa==='r'?0:fa==='l'?Math.PI:fa==='u'?-Math.PI/2:fa==='d'?Math.PI/2:
|
|
fa==='dr'?Math.PI/4:fa==='dl'?Math.PI*0.75:fa==='lu'?-Math.PI*0.75:-Math.PI/4;
|
|
a.ang=base+Math.sin(st.t*0.85+a.bob)*0.07;
|
|
}
|
|
}
|
|
|
|
/* ---------------- main step ---------------- */
|
|
function step(st,dt){
|
|
if(st.ended){st.endT+=dt;decayFx(st);return;}
|
|
st.timeLeft-=dt;
|
|
if(st.timeLeft<=0&&!st.planted){endRound(st,st.cfg.ctSide,'time');decayFx(st);return;}
|
|
st.t+=dt;
|
|
|
|
// scheduled attacker utility
|
|
st.utilPlan.forEach(u=>{
|
|
const key=''+u.t+'_'+u.type;
|
|
if(st.t>=u.t&&!st.utilDone.has(key)){
|
|
st.utilDone.add(key);
|
|
throwNade(st,u.type,u.x,u.y);
|
|
}
|
|
});
|
|
// nades
|
|
st.nades=st.nades.filter(n=>{
|
|
n.fuse-=dt;n.progress+=dt/n.total;n.z=Math.sin(Math.min(n.progress,1)*Math.PI)*3;
|
|
if(n.fuse<=0){detonate(st,n);return false;}
|
|
return true;
|
|
});
|
|
st.smokes.forEach(s=>s.life+=dt/17);
|
|
st.smokes=st.smokes.filter(s=>s.life<1);
|
|
st.fires.forEach(f=>{
|
|
f.life+=dt/5;
|
|
st.agents.forEach(a=>{
|
|
if(!a.dead&&Math.hypot(a.x-f.x,a.y-f.y)<f.r){a.hp-=dt*15;
|
|
if(a.hp<=0)kill(st,a,null,'🔥 Molly');}
|
|
});
|
|
});
|
|
st.fires=st.fires.filter(f=>f.life<1);
|
|
|
|
st.agents.forEach(a=>{
|
|
if(a.dead)return;
|
|
a.cool-=dt;
|
|
if(a.blind>0)a.blind-=dt;
|
|
if(a.hp<=0){kill(st,a,null,'🔥');return;}
|
|
// perception
|
|
let vis=null;
|
|
if(a.blind<=0){
|
|
if(st.t-(a._vt||-9)>=0.12){
|
|
let visD=1e9;
|
|
for(const e of st.agents){
|
|
if(e.dead||e.isCT===a.isCT)continue;
|
|
const d=Math.hypot(e.x-a.x,e.y-a.y);
|
|
if(d>Math.min(62,a.weapon.rng*1.5+8))continue;
|
|
const da=Math.atan2(e.y-a.y,e.x-a.x);
|
|
const diff=Math.abs(((da-a.ang)+Math.PI*3)%(Math.PI*2)-Math.PI);
|
|
if(d>3.5&&diff>1.2)continue;
|
|
if(los(st.g,a,e,st.smokes)&&d<visD){vis=e;visD=d;}
|
|
}
|
|
a._vis=vis;a._vt=st.t;
|
|
}
|
|
vis=a._vis&&!a._vis.dead?a._vis:null;
|
|
}
|
|
if(vis){
|
|
a.target=vis;a.seenX=vis.x;a.seenY=vis.y;a.lastSeenT=st.t;
|
|
st.agents.forEach(m=>{
|
|
if(m!==a&&!m.dead&&m.isCT===a.isCT&&Math.hypot(m.x-a.x,m.y-a.y)<26)m.alertT=st.t;
|
|
});
|
|
a.reactT=(a.reactT===null||a.reactT===undefined)?a.react*(0.7+Math.random()*0.6):a.reactT-dt;
|
|
a.ang=Math.atan2(vis.y-a.y,vis.x-a.x)+(Math.random()-0.5)*0.05;
|
|
if(a.reactT<=0&&a.cool<=0){shoot(st,a,vis);a.cool=60/a.weapon.rpm;}
|
|
// attackers keep pushing while trading (entry discipline by tactic)
|
|
if(!a.isCT&&!st.planted){
|
|
const gd=a.goal?Math.hypot(a.goal.x-a.x,a.goal.y-a.y):99;
|
|
const push=st.cfg.tactic[st.tKey]==='aggr'?1:0.45;
|
|
if(gd>7&&Math.random()<push)moveAlong(st,a,a.goal,dt);
|
|
}else a.moving=false;
|
|
return;
|
|
}
|
|
if(a.target&&(st.t-a.lastSeenT>1.3)){a.target=null;a.reactT=null;}
|
|
if(a.isCT)ctBrain(st,a,dt);else tBrain(st,a,dt);
|
|
});
|
|
|
|
// defuse
|
|
if(st.planted){
|
|
st.bombT-=dt;
|
|
if(st.bombT<=0){explodeBomb(st);return;}
|
|
if(st.defuser){
|
|
const du=st.defuser;
|
|
if(du.dead||Math.hypot(du.x-st.bombPos.x,du.y-st.bombPos.y)>2.4){st.defuser=null;st.defuseP=0;}
|
|
else{
|
|
st.defuseP+=dt/9;
|
|
if(st.defuseP>=1){endRound(st,st.cfg.ctSide,'defuse');return;}
|
|
}
|
|
}else{
|
|
for(const c of st.agents){
|
|
if(c.dead||!c.isCT)continue;
|
|
if(Math.hypot(c.x-st.bombPos.x,c.y-st.bombPos.y)<2.5&&!visAnyEnemy(st,c)){
|
|
st.defuser=c;c.moving=false;break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// gentle separation
|
|
const al=st.agents.filter(a=>!a.dead);
|
|
for(let i=0;i<al.length;i++)for(let j=i+1;j<al.length;j++){
|
|
const A=al[i],B=al[j];
|
|
const dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy);
|
|
if(d>0.01&&d<1.4){
|
|
const px=dx/d*(1.4-d)*0.5,py=dy/d*(1.4-d)*0.5;
|
|
if(walkable(st.g,A.x-px,A.y-py)){A.x-=px;A.y-=py;}
|
|
if(walkable(st.g,B.x+px,B.y+py)){B.x+=px;B.y+=py;}
|
|
}
|
|
}
|
|
decayFx(st);
|
|
}
|
|
function decayFx(st){
|
|
st.fx=st.fx.filter(f=>(st.t-f.t0)<f.dur);
|
|
st.pain*=0.985;st.shake*=0.9;
|
|
}
|
|
function explodeBomb(st){
|
|
st.fx.push({type:'boom',x:st.bombPos.x,y:st.bombPos.y,t0:st.t,dur:0.6});
|
|
st.fx.push({type:'flashPop',t0:st.t,dur:0.22});
|
|
const vz=VZ();if(vz)vz.sBoom();
|
|
st.agents.forEach(a=>{
|
|
if(!a.dead&&Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y)<10)a.hp-=200;
|
|
});
|
|
// mark deaths without recursion issues
|
|
st.agents.filter(a=>!a.dead&&a.hp<=0).forEach(a=>kill(st,a,null,'💥 C4'));
|
|
endRound(st,st.tKey,'bomb');
|
|
}
|
|
function endRound(st,winnerKey,reason){
|
|
if(st.ended)return;
|
|
st.ended=true;st.winner=winnerKey;st.reason=reason;st.endT=0;
|
|
const won=winnerKey===st.cfg.userSide;
|
|
const vz=VZ();if(vz)vz.sWinSting(won);
|
|
}
|
|
|
|
return {MAPS,buildGrid,astar,raycast,losClear,makeRound,step,WEAPONS,
|
|
ensureConnectedForTest:(L,g)=>{if(!L._conn){ensureConnected(L,g);L._conn=true;}}};
|
|
})();
|
|
|
|
if(typeof window!=='undefined')window.LIVE=LIVE;
|