/* ======================================== CYST-KID — Game Core ======================================== */ class Game{ constructor(){ this.cv=document.getElementById('gameCanvas'); this.cx=this.cv.getContext('2d'); this.snd=new Snd(); this.cv.width=CW;this.cv.height=CH; this.state='start'; this.lv=1;this.lives=3;this.sc=0;this.lsc=0; this.lt=0;this.ld=0;this.tt=0;this.td=0; this.dots=[];this.nDots=0;this.eDots=0; this.pl=null;this.pains=[]; this.fx=[];this.fr=0;this.lastT=0;this.acc=0; // Input this.ks={up:false,down:false,left:false,right:false,shift:false}; this.swD=null; this.rank=JSON.parse(localStorage.getItem('ckr3')||'[]'); this._inp();this._ui();this._rsz(); window.addEventListener('resize',()=>this._rsz()); this._rf=this._loop.bind(this);requestAnimationFrame(this._rf); } _rsz(){ const s=Math.min(window.innerWidth*.95/CW,window.innerHeight*.95/CH,2.5); this.cv.style.width=CW*s+'px';this.cv.style.height=CH*s+'px'; } // ---------- INPUT ---------- _inp(){ window.addEventListener('keydown',e=>{ if(e.target.tagName==='INPUT')return; let h=true; switch(e.code){ case'ArrowUp':case'KeyW':this.ks.up=true;break; case'ArrowDown':case'KeyS':this.ks.down=true;break; case'ArrowLeft':case'KeyA':this.ks.left=true;break; case'ArrowRight':case'KeyD':this.ks.right=true;break; case'ShiftLeft':case'ShiftRight':this.ks.shift=true;break; case'Escape':this._esc();break; case'Enter':case'NumpadEnter':this._ent();break; default:h=false; } if(h)e.preventDefault(); }); window.addEventListener('keyup',e=>{ switch(e.code){ case'ArrowUp':case'KeyW':this.ks.up=false;break; case'ArrowDown':case'KeyS':this.ks.down=false;break; case'ArrowLeft':case'KeyA':this.ks.left=false;break; case'ArrowRight':case'KeyD':this.ks.right=false;break; case'ShiftLeft':case'ShiftRight':this.ks.shift=false;break; } }); let tx=0,ty=0; this.cv.addEventListener('touchstart',e=>{e.preventDefault();tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:false}); this.cv.addEventListener('touchmove',e=>{e.preventDefault();const dx=e.touches[0].clientX-tx,dy=e.touches[0].clientY-ty;if(Math.abs(dx)+Math.abs(dy)<12)return;this.swD=Math.abs(dx)>Math.abs(dy)?(dx>0?'right':'left'):(dy>0?'down':'up');tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:false}); this._gpi=null; window.addEventListener('gamepadconnected',e=>{this._gpi=e.gamepad.index}); window.addEventListener('gamepaddisconnected',e=>{if(this._gpi===e.gamepad.index)this._gpi=null}); // Virtual joystick — global touch tracking so dragging outside the circle keeps working const base=document.getElementById('joystick-base'); const knob=document.getElementById('joystick-knob'); if(base&&knob){ const MAX=39; // max knob travel px const DEAD=10; // deadzone px let joyId=null, cx=0, cy=0; // active touch id and fixed center coords const moveKnob=(dx,dy)=>{ const dist=Math.hypot(dx,dy); const scale=Math.min(dist,MAX)/(dist||1); knob.style.transform=`translate(calc(-50% + ${dx*scale}px), calc(-50% + ${dy*scale}px))`; }; const applyDir=(dx,dy)=>{ this.ks.up=this.ks.down=this.ks.left=this.ks.right=false; if(Math.hypot(dx,dy)Math.abs(dy)){this.ks[dx>0?'right':'left']=true} else{this.ks[dy>0?'down':'up']=true} }; const reset=()=>{ joyId=null; this.ks.up=this.ks.down=this.ks.left=this.ks.right=false; knob.style.transform='translate(-50%, -50%)'; }; // touchstart only on the base — anchors the joystick center base.addEventListener('touchstart',e=>{ e.preventDefault(); if(joyId!==null)return; // ignore second finger const t=e.changedTouches[0]; joyId=t.identifier; const r=base.getBoundingClientRect(); cx=r.left+r.width/2; cy=r.top+r.height/2; const dx=t.clientX-cx, dy=t.clientY-cy; moveKnob(dx,dy); applyDir(dx,dy); },{passive:false}); // move and end tracked globally so dragging outside the element keeps working document.addEventListener('touchmove',e=>{ if(joyId===null)return; const t=Array.from(e.changedTouches).find(x=>x.identifier===joyId); if(!t)return; e.preventDefault(); const dx=t.clientX-cx, dy=t.clientY-cy; moveKnob(dx,dy); applyDir(dx,dy); },{passive:false}); document.addEventListener('touchend',e=>{ if(joyId===null)return; if(Array.from(e.changedTouches).some(x=>x.identifier===joyId))reset(); }); document.addEventListener('touchcancel',e=>{ if(joyId===null)return; if(Array.from(e.changedTouches).some(x=>x.identifier===joyId))reset(); }); } } _gp(){if(this._gpi===null)return{};const g=navigator.getGamepads()[this._gpi];if(!g)return{};let d=null;const z=.3;if(Math.abs(g.axes[0])>Math.abs(g.axes[1])){if(g.axes[0]<-z)d='left';else if(g.axes[0]>z)d='right'}else{if(g.axes[1]<-z)d='up';else if(g.axes[1]>z)d='down'}if(g.buttons[0]?.pressed)this._ent();if(g.buttons[8]?.pressed)this._esc();return{dir:d,sprint:g.buttons[5]?.pressed||g.buttons[7]?.pressed}} // Which direction key is currently held? _dir(){ let d=null; if(this.ks.up)d='up'; if(this.ks.down)d='down'; if(this.ks.left)d='left'; if(this.ks.right)d='right'; if(this.swD){d=this.swD;this.swD=null} const gp=this._gp(); if(gp.dir)d=gp.dir; return d; } // ---------- UI BUTTON BINDINGS ---------- _ui(){ document.getElementById('startBtn').onclick=()=>{this.snd.init();this._start()}; document.getElementById('nextLevelBtn').onclick=()=>this._ent(); document.getElementById('restartBtn').onclick=()=>{this.sc=0;this._ov('start')}; document.getElementById('submitScoreBtn').onclick=()=>this._sub(); document.getElementById('playAgainBtn').onclick=()=>{this.sc=0;this._ov('start')}; document.getElementById('go-submitScoreBtn').onclick=()=>this._subGO(); document.getElementById('playerName').addEventListener('keydown',e=>{if(e.key==='Enter'){e.stopPropagation();this._sub()}}); document.getElementById('go-playerName').addEventListener('keydown',e=>{if(e.key==='Enter'){e.stopPropagation();this._subGO()}}); } _esc(){if(this.state==='playing'){this.sc=0;this.lsc=0;this.state='start';this._ov('start')}} _ent(){ if(this.state==='lvlDone'){this.lv++;this._init();this.state='playing';this._ov(null)} else if(this.state==='over'||this.state==='win'){this.sc=0;this._ov('start')} else if(this.state==='start'){this.snd.init();this._start()} } // ---------- OVERLAY / SCREEN MANAGER ---------- _ov(n){ ['start-screen','level-complete-screen','game-over-screen','win-screen'].forEach(id=>document.getElementById(id).style.display='none'); if(n==='start') this._showStart(); if(n==='lvlDone') this._showLvlDone(); if(n==='over') this._showGameOver(); if(n==='win') this._showWin(); } // ---------- LIFECYCLE ---------- _init(){ this.dots=[];this.nDots=0;this.eDots=0; this.meds=MED_SPAWNS.map(sp=>({r:sp.r,c:sp.c,eaten:true,pulse:0,respT:0})); for(let r=0;rthis.pains.push({ x:h.c*TILE, y:h.r*TILE+HUD_TOP, gc:h.c, gr:h.r, dir:'up', moving:false, spd:GHOST_SPD_BASE+this.lv*GHOST_SPD_LEVEL, strat:st[i], scared:false, eaten:i>=2, inHouse:true, leaving:false, relT:i<2?i*GHOST_REL_INTERVAL+GHOST_REL_OFFSET:999999, dormant:i>=2, pulse:Math.random()*6.28 })); } // -------- MAIN LOOP -------- _loop(ts){ if(!this.lastT)this.lastT=ts; const dt=ts-this.lastT;this.lastT=ts; if(this.state==='playing'){this.acc+=dt;let s=0;while(this.acc>=TICK&&s<5){this._upd();this.acc-=TICK;s++}if(this.acc>TICK*5)this.acc=0} this._draw();requestAnimationFrame(this._rf); } // -------- UPDATE -------- _upd(){ this.fr++;this.lt++; const liveDir=this._dir(); if(liveDir) this.qDir=liveDir; const wantDir=this.qDir; const base=BASE_SPD+(this.lv-1)*GHOST_SPD_LEVEL; const gp=this._gp(); const sprint=this.ks.shift||(gp.sprint||false); this.pl.spd=this.sup?base*SPEED_MULT:(sprint?base*SPEED_MULT:base); this._movePl(wantDir); this._movePains(); this._coll(); this._tickMeds(); this._tickChall(); this.fx=this.fx.filter(f=>{f.t--;return f.t>0}); this._chkDone(); } // -------- COLLISIONS -------- _coll(){ for(const p of this.pains){ if(p.eaten||p.inHouse||p.leaving)continue; if(Math.abs(this.pl.x-p.x)+Math.abs(this.pl.y-p.y)this._ov('over'),600)} else{ this.pl.x=PL0.c*TILE;this.pl.y=PL0.r*TILE+HUD_TOP;this.pl.gc=PL0.c;this.pl.gr=PL0.r;this.pl.dir='right';this.pl.moving=false; this.qDir=null;this.swD=null;this.ks.up=this.ks.down=this.ks.left=this.ks.right=false; PH.forEach((h,i)=>{const p=this.pains[i]; if(p.dormant){p.inHouse=true;p.leaving=false;p.eaten=true;p.relT=999999;p.moving=false;return} p.x=h.c*TILE;p.y=h.r*TILE+HUD_TOP;p.gc=h.c;p.gr=h.r;p.inHouse=true;p.leaving=false;p.eaten=false;p.relT=i*GHOST_REL_INTERVAL+GHOST_REL_OFFSET;p.moving=false}); this._endChall(); } } _tickMeds(){ for(const m of this.meds){ if(m.eaten){if(m.respT>0&&--m.respT<=0){m.eaten=false;m.pulse=0}} else{m.pulse+=0.08} } } _tickChall(){ if(!this.challActive){if(--this.nextChallIn<=0)this._startChall()} else{if(--this.challTimer<=0)this._failChall()} } _startChall(){ if(!MED_SPAWNS||!MED_SPAWNS.length)return; const gi=this.pains.findIndex(p=>p.dormant); if(gi<0)return; // pick patient position, far from player const cells=[]; for(let r=1;r5)cells.push({r,c}); if(!cells.length)return; const pc=cells[0|Math.random()*cells.length]; // activate a random med spawn const avail=this.meds.filter(m=>m.eaten); if(!avail.length)return; const med=avail[0|Math.random()*avail.length]; med.eaten=false;med.pulse=0; // activate ghost const g=this.pains[gi]; g.dormant=false;g.inHouse=true;g.leaving=false;g.eaten=false; g.relT=FPS*CHALL_DURATION; this.patient={r:pc.r,c:pc.c}; this.challActive=true;this.challTimer=FPS*CHALL_DURATION;this.challGhostIdx=gi; } _succChall(){ const g=this.pains[this.challGhostIdx]; this.fx.push({x:g.x+TILE/2,y:g.y+TILE/2,co:'#00ff88',t:40,mx:40,tp:'burst'}); g.eaten=true;g.dormant=true;g.inHouse=true;g.leaving=false;g.relT=999999; this.lsc+=SC_CHALL;this.snd.laser(); this._endChall(); } _failChall(){ if(this.challGhostIdx>=0)this.pains[this.challGhostIdx].relT=0; this._endChall(); } _endChall(){ this.meds.filter(m=>!m.eaten).forEach(m=>{m.eaten=true;}); this.patient=null; this.challActive=false;this.challTimer=0;this.challGhostIdx=-1; this.hasMed=false; this.nextChallIn=(FPS*(CHALL_NEXT_MIN+Math.random()*(CHALL_NEXT_MAX-CHALL_NEXT_MIN)))|0; } _chkDone(){ if(this.eDots>=this.nDots){ this.sc+=this.lsc;this.lsc=0;this.tt+=this.lt;this.td+=this.ld;this.snd.lvlUp(); if(this.lv>=WIN_LEVEL){this.state='win';setTimeout(()=>this._ov('win'),800)} else{this.state='lvlDone';setTimeout(()=>this._ov('lvlDone'),800)} } } } window.addEventListener('load',()=>new Game());