From 9cc8ac8cad28211e45a3a48baa7fe0f76451950b Mon Sep 17 00:00:00 2001 From: verboomp Date: Thu, 16 Apr 2026 08:14:20 +0200 Subject: [PATCH] Added kidney_labe and Cyste_kid --- Cyst_Kid/index.html | 72 ++ Cyst_Kid/js/constants.js | 115 +++ Cyst_Kid/js/game.js | 266 +++++++ Cyst_Kid/js/movement.js | 213 +++++ Cyst_Kid/js/render.js | 92 +++ Cyst_Kid/js/screen-game-over.js | 11 + Cyst_Kid/js/screen-level-complete.js | 11 + Cyst_Kid/js/screen-start.js | 15 + Cyst_Kid/js/screen-win.js | 28 + Cyst_Kid/js/sound.js | 12 + Cyst_Kid/level-designer.html | 872 +++++++++++++++++++++ Cyst_Kid/style.css | 264 +++++++ WEB-INF/jboss-web.xml | 4 + WEB-INF/web.xml | 4 + index.html | 21 + kidney_lab/CLAUDE.md | 67 ++ kidney_lab/css/styles.css | 637 +++++++++++++++ kidney_lab/description.txt | 40 + kidney_lab/index.html | 50 ++ kidney_lab/js/conveyor.js | 200 +++++ kidney_lab/js/game.js | 1080 ++++++++++++++++++++++++++ kidney_lab/js/highscore.js | 144 ++++ kidney_lab/js/intro.js | 140 ++++ kidney_lab/js/lab.js | 84 ++ kidney_lab/js/player.js | 140 ++++ kidney_lab/js/settings.js | 101 +++ kidney_lab/readme.txt | 24 + sde/game.js | 970 +++++++++++++++++++++++ sde/index.html | 130 ++++ sde/js/audio.js | 12 + sde/js/constants.js | 24 + sde/js/gameloop.js | 287 +++++++ sde/js/input.js | 17 + sde/js/main.js | 3 + sde/js/render.js | 250 ++++++ sde/js/screens.js | 58 ++ sde/js/state.js | 31 + sde/js/utils.js | 13 + sde/js/world.js | 191 +++++ sde/style.css | 69 ++ 40 files changed, 6762 insertions(+) create mode 100644 Cyst_Kid/index.html create mode 100644 Cyst_Kid/js/constants.js create mode 100644 Cyst_Kid/js/game.js create mode 100644 Cyst_Kid/js/movement.js create mode 100644 Cyst_Kid/js/render.js create mode 100644 Cyst_Kid/js/screen-game-over.js create mode 100644 Cyst_Kid/js/screen-level-complete.js create mode 100644 Cyst_Kid/js/screen-start.js create mode 100644 Cyst_Kid/js/screen-win.js create mode 100644 Cyst_Kid/js/sound.js create mode 100644 Cyst_Kid/level-designer.html create mode 100644 Cyst_Kid/style.css create mode 100644 WEB-INF/jboss-web.xml create mode 100644 WEB-INF/web.xml create mode 100644 index.html create mode 100644 kidney_lab/CLAUDE.md create mode 100644 kidney_lab/css/styles.css create mode 100644 kidney_lab/description.txt create mode 100644 kidney_lab/index.html create mode 100644 kidney_lab/js/conveyor.js create mode 100644 kidney_lab/js/game.js create mode 100644 kidney_lab/js/highscore.js create mode 100644 kidney_lab/js/intro.js create mode 100644 kidney_lab/js/lab.js create mode 100644 kidney_lab/js/player.js create mode 100644 kidney_lab/js/settings.js create mode 100644 kidney_lab/readme.txt create mode 100644 sde/game.js create mode 100644 sde/index.html create mode 100644 sde/js/audio.js create mode 100644 sde/js/constants.js create mode 100644 sde/js/gameloop.js create mode 100644 sde/js/input.js create mode 100644 sde/js/main.js create mode 100644 sde/js/render.js create mode 100644 sde/js/screens.js create mode 100644 sde/js/state.js create mode 100644 sde/js/utils.js create mode 100644 sde/js/world.js create mode 100644 sde/style.css diff --git a/Cyst_Kid/index.html b/Cyst_Kid/index.html new file mode 100644 index 0000000..9e7a221 --- /dev/null +++ b/Cyst_Kid/index.html @@ -0,0 +1,72 @@ + + + + + + Cyst-Kid + + + +
+ +
+
+

CYST-KID

+

STARTTEXT FOLGT NOCH

+
+

Steuerung: Pfeiltasten / WASD

+

Sprint: Shift

+

ESC: Zurück zum Start

+
+ +
+
+ + + +
+ + +
+
+ + + + +
+
+
+ + + + + + + + + + + + diff --git a/Cyst_Kid/js/constants.js b/Cyst_Kid/js/constants.js new file mode 100644 index 0000000..8b06093 --- /dev/null +++ b/Cyst_Kid/js/constants.js @@ -0,0 +1,115 @@ +/* ======================================== + CYST-KID — Shared Constants & Helpers + ======================================== */ + +const COLS = 28; +const ROWS = 22; + +/* 0=dot 1=wall 2=empty 3=ghostWall 4=ghostDoor 5=tunnel 8=ghostInside */ +const MAP = [ +[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],//0 +[1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1],//1 +[1,0,1,1,1,0,1,1,1,1,1,1,0,1,1,0,1,1,1,1,1,1,0,1,1,1,0,1],//2 +[1,0,1,1,1,0,1,1,1,1,1,1,0,1,1,0,1,1,1,1,1,1,0,1,1,1,0,1],//3 +[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],//4 +[1,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,1,0,1],//5 +[1,0,0,0,0,0,1,0,0,0,0,1,0,1,1,0,1,0,0,0,0,1,0,0,0,0,0,1],//6 +[1,1,1,1,1,0,1,1,1,1,0,1,0,1,1,0,1,0,1,1,1,1,0,1,1,1,1,1],//7 +[2,2,2,2,1,0,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,0,1,2,2,2,2],//8 +[1,1,1,1,1,0,1,2,1,1,1,3,3,4,4,3,3,1,1,1,2,1,0,1,1,1,1,1],//9 +[5,2,2,2,2,0,2,2,1,8,8,8,8,8,8,8,8,8,8,1,2,2,0,2,2,2,2,5],//10 +[1,1,1,1,1,0,1,2,1,8,8,8,8,8,8,8,8,8,8,1,2,1,0,1,1,1,1,1],//11 +[2,2,2,2,1,0,1,2,1,1,1,1,1,1,1,1,1,1,1,1,2,1,0,1,2,2,2,2],//12 +[1,1,1,1,1,0,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,0,1,1,1,1,1],//13 +[1,0,0,0,0,0,0,0,0,1,1,0,1,1,1,1,0,1,1,0,0,0,0,0,0,0,0,1],//14 +[1,0,1,1,1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,0,1,1,1,0,1],//15 +[1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],//16 +[1,1,1,0,1,0,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,0,1,1,1],//17 +[1,0,0,0,0,0,1,0,0,0,0,1,0,1,1,0,1,0,0,0,0,1,0,0,0,0,0,1],//18 +[1,0,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,0,1,1,1,1,1,1,1,1,0,1],//19 +[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],//20 +[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],//21 +]; + +const PL0 = {c:14,r:16}; +const PH = [{c:12,r:10},{c:15,r:10},{c:12,r:11},{c:15,r:11}]; + +const TILE = 16; +const HUD_TOP = 44; +const HUD_BOT = 44; +const CW = COLS * TILE; +const CH = ROWS * TILE + HUD_TOP + HUD_BOT; +const FPS = 60; +const TICK = 1000 / FPS; +const BASE_SPD = 2; +const SUPER_SEC = 10; + +const CO = { + bg:'#87CEEB', wall:'#8B5CF6', we:'#A78BFA', + dot:'#FFF', cys:'#FFB300', cysE:'#FF8F00', + bag:'#88CC44', bagS:'#222', pB:'#DD2222', + pBo:'#FFD700', pSc:'#22CC66', kid:'#CC4444', + kE:'#FFF', sup:'#00DDFF', hud:'#111133', hudT:'#FFF', + gD:'#FFD700', gW:'#6D28D9' +}; + +const T_ROW = 10; // tunnel row +const DR={up:-1,down:1,left:0,right:0}; +const DC={up:0,down:0,left:-1,right:1}; +const OPP={up:'down',down:'up',left:'right',right:'left'}; +const DIRS=['up','down','left','right']; + +// tile query helpers +function tile(r,c){return(r>=0&&r=0&&c=COLS))return true; + const t=tile(r,c);return t===0||t===2||t===5; +} +function painOk(r,c){ + if(r===T_ROW&&(c<0||c>=COLS))return true; + const t=tile(r,c);return t===0||t===2||t===5; +} + +//const COLS = 28; +//const ROWS = 28; + +//const PL0 = {c:14,r:23}; +//const PH = [{c:12,r:10},{c:15,r:10},{c:12,r:11},{c:15,r:11}]; + + +/* 0=dot 1=wall 2=empty 3=ghostWall 4=ghostDoor 5=tunnel 8=ghostInside */ +/* +const MAP = [ +[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],//0 +[1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1],//1 +[1,0,1,1,1,0,1,1,1,1,1,1,0,1,1,0,1,1,1,1,1,1,0,1,1,1,0,1],//2 +[1,0,1,1,1,0,1,1,1,1,1,1,0,1,1,0,1,1,1,1,1,1,0,1,1,1,0,1],//3 +[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],//4 +[1,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,1,0,1],//5 +[1,0,0,0,0,0,1,0,0,0,0,1,0,1,1,0,1,0,0,0,0,1,0,0,0,0,0,1],//6 +[1,1,1,1,1,0,1,1,1,1,0,1,0,1,1,0,1,0,1,1,1,1,0,1,1,1,1,1],//7 +[2,2,2,2,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,2,2,2,2],//8 +[1,1,1,1,1,0,1,0,1,1,1,3,3,4,4,3,3,1,1,1,0,1,0,1,1,1,1,1],//9 +[5,2,2,2,2,0,0,0,1,8,8,8,8,8,8,8,8,8,8,1,0,0,0,2,2,2,2,5],//10 +[1,1,1,1,1,0,1,0,1,8,8,8,8,8,8,8,8,8,8,1,0,1,0,1,1,1,1,1],//11 +[2,2,2,2,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,2,2,2,2],//12 +[1,1,1,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1],//13 +[1,0,0,0,0,0,0,0,0,1,1,0,1,1,1,1,0,1,1,0,0,0,0,0,0,0,0,1],//14 +[1,0,1,1,1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,0,1,1,1,0,1],//15 +[1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],//16 +[1,1,1,0,1,0,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,0,1,1,1],//17 +[1,0,0,0,0,0,1,0,0,0,0,1,0,1,1,0,1,0,0,0,0,1,0,0,0,0,0,1],//18 +[1,0,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,0,1,1,1,1,1,1,1,1,0,1],//19 +[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],//20 +[1,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,1,1,1,1,0,1,0,1,1,1,0,1],//24 +[1,0,0,0,0,0,1,0,0,0,0,1,0,1,1,0,1,0,0,0,0,1,0,0,0,0,0,1],//25 +[1,1,1,1,1,0,1,1,1,1,0,0,0,0,0,0,0,0,1,1,1,1,0,1,1,1,1,1],//26 +[1,0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,1],//27 +[1,0,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,0,1,1,1,1,1,1,1,1,0,1],//28 +[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],//29 +[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],//30 +];*/ + + + diff --git a/Cyst_Kid/js/game.js b/Cyst_Kid/js/game.js new file mode 100644 index 0000000..3a0d878 --- /dev/null +++ b/Cyst_Kid/js/game.js @@ -0,0 +1,266 @@ +/* ======================================== + 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.cysts=[];this.bios=[]; + this.ateSt=false;this.ateBi=false;this.combD=0;this.combN=1; + this.sup=false;this.supT=0; + 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=>{ + 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._start()}; + document.getElementById('submitScoreBtn').onclick=()=>this._sub(); + document.getElementById('playAgainBtn').onclick=()=>this._ov('start'); +} + +_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.sc=0;this._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; + 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:1.2+this.lv*0.2, + strat:st[i], scared:false, eaten:false, + inHouse:true, leaving:false, + relT:i*80+50, + pulse:Math.random()*6.28 + })); + + this.cysts=[]; + this._rp(this.lv,1,8).forEach(p=>this.cysts.push({r:p.r,c:p.c,e:false,v:true})); + this.bios=[]; +} + +_rp(n,mr,xr){ + const cs=[];for(let r=mr;r<=xr;r++)for(let c=1;c0;i--){const j=0|Math.random()*(i+1);[cs[i],cs[j]]=[cs[j],cs[i]]} + const o=[];for(const p of cs){if(o.length>=n)break;if(o.every(q=>Math.abs(q.r-p.r)+Math.abs(q.c-p.c)>=4))o.push(p)}return o; +} + +// -------- 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)*0.2; + const gp=this._gp(); + const sprint=this.ks.shift||(gp.sprint||false); + this.pl.spd=this.sup?base*2:(sprint?base*2:base); + + this._movePl(wantDir); + this._movePains(); + this._coll(); + if(this.sup){this.supT--;if(this.supT<=0)this._esup()} + 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];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*90+60;p.moving=false}); + } +} + +_chkDone(){ + if(this.eDots>=this.nDots&&this.cysts.every(c=>c.e)&&this.combD>=this.combN){ + this.sc+=this.lsc;this.tt+=this.lt;this.td+=this.ld;this.snd.lvlUp(); + if(this.lv>=3){this.state='win';setTimeout(()=>this._ov('win'),800)} + else{this.state='lvlDone';setTimeout(()=>this._ov('lvlDone'),800)} + } +} +} + +window.addEventListener('load',()=>new Game()); diff --git a/Cyst_Kid/js/movement.js b/Cyst_Kid/js/movement.js new file mode 100644 index 0000000..6922d43 --- /dev/null +++ b/Cyst_Kid/js/movement.js @@ -0,0 +1,213 @@ +// ========== MOVEMENT ========== +// Extends Game.prototype — must be loaded after game.js + +Object.assign(Game.prototype, { + +// -------- ENTITY MOVEMENT SYSTEM -------- +// Core idea: An entity is either AT a tile center (moving=false) +// or IN TRANSIT to the next tile (moving=true). +// When at center, pick a direction. Then move pixel-by-pixel toward the +// next tile center in that direction. Once the center is reached or passed, +// snap to it and set moving=false again. + +_moveEntity(ent, spd, pickDir){ + if(!ent.moving){ + const dir = pickDir(ent); + if(dir){ ent.dir = dir; ent.moving = true; } + return; + } + const dx = DC[ent.dir] * spd; + const dy = DR[ent.dir] * spd; + ent.x += dx; ent.y += dy; + const tgtC = ent.gc + DC[ent.dir]; + const tgtR = ent.gr + DR[ent.dir]; + const tgtX = tgtC * TILE; + const tgtY = tgtR * TILE + HUD_TOP; + let reached = false; + if(ent.dir==='right' && ent.x >= tgtX) reached=true; + if(ent.dir==='left' && ent.x <= tgtX) reached=true; + if(ent.dir==='down' && ent.y >= tgtY) reached=true; + if(ent.dir==='up' && ent.y <= tgtY) reached=true; + if(reached){ + ent.gc = tgtC; ent.gr = tgtR; + ent.x = tgtC * TILE; ent.y = tgtR * TILE + HUD_TOP; + ent.moving = false; + } +}, + +// -------- PLAYER MOVEMENT -------- +_movePl(wantDir){ + const p = this.pl; + const spd = p.spd; + + this._moveEntity(p, spd, (ent) => { + // This function is called when player is at tile center. + // Return the direction to move, or null to stay. + + // Try the queued direction (allows turning at the next opening) + if(wantDir){ + const nr = ent.gr + DR[wantDir]; + const nc = ent.gc + DC[wantDir]; + if(plOk(nr, nc)){ + ent.lastFace = wantDir; + return wantDir; + } + // Queued turn not yet possible — keep moving straight + const nr2 = ent.gr + DR[ent.dir]; + const nc2 = ent.gc + DC[ent.dir]; + if(plOk(nr2, nc2)) return ent.dir; + } + // No input yet or fully blocked — stay put + return null; + }); + + // Handle tunnel wrap AFTER movement + if(p.gr === T_ROW){ + if(p.gc < 0){ + p.gc = COLS-1; p.x = p.gc*TILE; + p.gr = T_ROW; p.y = T_ROW*TILE+HUD_TOP; + p.moving = false; + this._onTun(); + return; + } + if(p.gc >= COLS){ + p.gc = 0; p.x = 0; + p.gr = T_ROW; p.y = T_ROW*TILE+HUD_TOP; + p.moving = false; + this._onTun(); + return; + } + } + + // Allow changing direction mid-transit if the player wants to reverse (180°) + if(p.moving && wantDir && wantDir === OPP[p.dir]){ + p.dir = wantDir; + p.lastFace = wantDir; + } + + // Allow turning at intersections mid-transit if close to grid on perpendicular axis + if(p.moving && wantDir && wantDir !== p.dir && wantDir !== OPP[p.dir]){ + const cx = p.gc * TILE; + const cy = p.gr * TILE + HUD_TOP; + const isHoriz = (p.dir==='left'||p.dir==='right'); + const aligned = isHoriz ? Math.abs(p.y - cy) < 1 : Math.abs(p.x - cx) < 1; + if(aligned){ + const snapC = Math.round(p.x / TILE); + const snapR = Math.round((p.y - HUD_TOP) / TILE); + const nr = snapR + DR[wantDir]; + const nc = snapC + DC[wantDir]; + if(plOk(nr, nc)){ + p.gc = snapC; p.gr = snapR; + p.x = snapC * TILE; p.y = snapR * TILE + HUD_TOP; + p.dir = wantDir; + p.lastFace = wantDir; + } + } + } + + if(wantDir) p.lastFace = wantDir; + + // Mouth anim + if(p.moving){ + p.mouth+=.15*p.mouthD;if(p.mouth>1){p.mouth=1;p.mouthD=-1}if(p.mouth<0){p.mouth=0;p.mouthD=1} + } + + // Eat dots + for(const d of this.dots)if(!d.e&&d.r===p.gr&&d.c===p.gc){d.e=true;this.eDots++;this.lsc++;if(this.fr%4===0)this.snd.bite()} + // Eat cystine + for(const cs of this.cysts)if(cs.v&&!cs.e&&cs.r===p.gr&&cs.c===p.gc){ + cs.e=true;cs.v=false;this.ateSt=true;this.lsc+=10;this.snd.laser(); + this.fx.push({x:p.x+TILE/2,y:p.y+TILE/2,co:'#FFD700',t:30,mx:30,tp:'glow'}); + this.cysts.forEach(x=>{if(!x.e)x.v=false});this._sBio(); + } + // Eat bio + for(let i=this.bios.length-1;i>=0;i--){const b=this.bios[i];if(b.r===p.gr&&b.c===p.gc){ + this.bios.splice(i,1);this.ateBi=true;this.lsc+=10;this.snd.laser(); + this.fx.push({x:p.x+TILE/2,y:p.y+TILE/2,co:'#FFD700',t:30,mx:30,tp:'glow'}); + }} +}, + +_onTun(){ + if(this.ateSt&&this.ateBi&&!this.sup){ + this._sSup();this.ateSt=false;this.ateBi=false;this.combD++;this.lsc+=20; + } +}, + +_sBio(){ + const cs=[];for(let r=1;r5)cs.push({r,c}); + if(cs.length)this.bios.push(cs[0|Math.random()*cs.length]); +}, + +_sSup(){ + this.sup=true;this.supT=SUPER_SEC*FPS;this.snd.laser(); + this.pains.forEach(p=>p.scared=true); + this.fx.push({x:this.pl.x+TILE/2,y:this.pl.y+TILE/2,co:CO.sup,t:40,mx:40,tp:'glow'}); +}, + +_esup(){ + this.sup=false; + PH.forEach((h,i)=>{const p=this.pains[i];p.scared=false;p.eaten=false;p.inHouse=true;p.leaving=false;p.x=h.c*TILE;p.y=h.r*TILE+HUD_TOP;p.gc=h.c;p.gr=h.r;p.relT=i*60+30;p.moving=false}); + if(this.combD!c.e);const pos=this._rp(rem.length,1,20);rem.forEach((cs,i)=>{if(pos[i]){cs.r=pos[i].r;cs.c=pos[i].c;cs.v=true}})} +}, + +// -------- PAIN MOVEMENT -------- +_movePains(){ + for(const p of this.pains){ + if(p.eaten)continue; + p.pulse+=.06; + + // In house + if(p.inHouse){ + p.relT--; + if(p.relT<=0){ + p.inHouse=false;p.leaving=true; + p.gc=13;p.gr=9;p.x=p.gc*TILE;p.y=p.gr*TILE+HUD_TOP; + p.dir='up';p.moving=false; + } + continue; + } + + // Leaving house — move upward through door to row 7 + if(p.leaving){ + p.y-=p.spd; + const tgtY=7*TILE+HUD_TOP; + if(p.y<=tgtY){ + p.gr=7;p.gc=13;p.x=p.gc*TILE;p.y=p.gr*TILE+HUD_TOP; + p.leaving=false;p.moving=false;p.dir='left'; + } + continue; + } + + // Normal movement using the entity system + const spd=p.scared?p.spd*.55:p.spd; + + this._moveEntity(p, spd, (ent)=>{ + const valid=DIRS.filter(d=>{ + const nr=ent.gr+DR[d], nc=ent.gc+DC[d]; + return painOk(nr,nc); + }); + const fwd=valid.filter(d=>d!==OPP[ent.dir]); + const ch=fwd.length>0?fwd:valid; + if(ch.length===0)return null; + return this._ppd(ent,ch); + }); + + // Tunnel wrap + if(p.gc<0){p.gc=COLS-1;p.x=p.gc*TILE;p.moving=false} + if(p.gc>=COLS){p.gc=0;p.x=0;p.moving=false} + } +}, + +_ppd(pain,ch){ + const pr=this.pl.gr,pc=this.pl.gc; + const dist=d=>{const nr=pain.gr+DR[d],nc=pain.gc+DC[d];return Math.abs(nr-pr)+Math.abs(nc-pc)}; + if(pain.scared)return ch.reduce((a,b)=>dist(a)>dist(b)?a:b); + switch(pain.strat){ + case'chase':return ch.reduce((a,b)=>dist(a){const nr=pain.gr+DR[d],nc=pain.gc+DC[d];return Math.abs(nr-ar)+Math.abs(nc-ac)};return ch.reduce((a,b)=>ad(a)dist(a){const rr=r+dr,cc=col+dc;if(rr<0||rr>=ROWS||cc<0||cc>=COLS)return true;const v=MAP[rr][cc];return v===1||v===3}; + if(!w(-1,0)){c.beginPath();c.moveTo(x,y+1.5);c.lineTo(x+TILE,y+1.5);c.stroke()} + if(!w(1,0)){c.beginPath();c.moveTo(x,y+TILE-1.5);c.lineTo(x+TILE,y+TILE-1.5);c.stroke()} + if(!w(0,-1)){c.beginPath();c.moveTo(x+1.5,y);c.lineTo(x+1.5,y+TILE);c.stroke()} + if(!w(0,1)){c.beginPath();c.moveTo(x+TILE-1.5,y);c.lineTo(x+TILE-1.5,y+TILE);c.stroke()} + } + else if(t===3){c.fillStyle=CO.gW;c.fillRect(x,y,TILE,TILE)} + else if(t===4){c.fillStyle=CO.gD;c.fillRect(x,y+TILE*.35,TILE,TILE*.3)} + } +}, + +_tLbl(c){ + const ty=T_ROW*TILE+HUD_TOP,h=TILE;c.save(); + c.fillStyle='#7C3AED';c.beginPath();c.moveTo(2,ty+h/2);c.lineTo(18,ty-4);c.lineTo(18,ty+h+4);c.closePath();c.fill(); + c.fillRect(18,ty-2,72,h+4);c.fillStyle='#FFF';c.font='bold 6px "Press Start 2P",monospace';c.textAlign='center';c.textBaseline='middle'; + c.fillText('ANALYSE',54,ty+h/2-3);c.fillText('ZENTRUM',54,ty+h/2+5); + c.fillStyle='#7C3AED';c.beginPath();c.moveTo(CW-2,ty+h/2);c.lineTo(CW-18,ty-4);c.lineTo(CW-18,ty+h+4);c.closePath();c.fill(); + c.fillRect(CW-90,ty-2,72,h+4);c.fillStyle='#FFF';c.fillText('ANALYSE',CW-54,ty+h/2-3);c.fillText('ZENTRUM',CW-54,ty+h/2+5);c.restore(); +}, + +_dDots(c){c.fillStyle=CO.dot;for(const d of this.dots)if(!d.e)c.fillRect(d.c*TILE+TILE/2-2,d.r*TILE+HUD_TOP+TILE/2-2,4,4)}, + +_dCyst(c){ + for(const cs of this.cysts){if(!cs.v||cs.e)continue;const x=cs.c*TILE+TILE/2,y=cs.r*TILE+HUD_TOP+TILE/2,sz=TILE*.45; + c.fillStyle=CO.cys;c.strokeStyle=CO.cysE;c.lineWidth=1.5;c.beginPath(); + for(let i=0;i<6;i++){const a=i*Math.PI/3-Math.PI/6,px=x+Math.cos(a)*sz,py=y+Math.sin(a)*sz;i===0?c.moveTo(px,py):c.lineTo(px,py)} + c.closePath();c.fill();c.stroke();const sh=.3+.3*Math.sin(this.fr*.1);c.fillStyle=`rgba(255,255,200,${sh})`;c.fillRect(x-2,y-2,4,4)} +}, + +_dBio(c){ + for(const b of this.bios){const x=b.c*TILE+TILE/2,y=b.r*TILE+HUD_TOP+TILE/2,s=TILE*.38; + c.fillStyle=CO.bag;c.beginPath();c.moveTo(x-s,y+s);c.lineTo(x-s*.7,y-s*.5);c.lineTo(x-s*.3,y-s*.9);c.lineTo(x+s*.3,y-s*.9);c.lineTo(x+s*.7,y-s*.5);c.lineTo(x+s,y+s);c.closePath();c.fill(); + c.fillStyle=CO.bagS;c.font=`${TILE*.5}px Arial`;c.textAlign='center';c.textBaseline='middle';c.fillText('☣',x,y+1)} +}, + +_dPain(c){ + for(const p of this.pains){if(p.eaten)continue;const x=p.x+TILE/2,y=p.y+TILE/2,pu=1+.06*Math.sin(p.pulse),r=TILE*.44*pu; + c.save();c.translate(x,y);c.fillStyle=p.scared?CO.pSc:CO.pB;c.beginPath();c.arc(0,2,r,Math.PI,0,false);c.lineTo(r,4);c.lineTo(-r,4);c.closePath();c.fill(); + if(!p.scared){c.strokeStyle=CO.pBo;c.lineWidth=1.5;[-r*.5,0,r*.5].forEach(bx=>{c.beginPath();c.moveTo(bx,-r*.15);c.lineTo(bx-2,-r*.55);c.lineTo(bx+1,-r*.45);c.lineTo(bx-1,-r*.9);c.stroke()})} + else{c.fillStyle='#FFF';c.fillRect(-3,-4,2,2);c.fillRect(2,-4,2,2);c.strokeStyle='#FFF';c.lineWidth=1;c.beginPath();for(let i=0;i<5;i++){const qx=-4+i*2;i===0?c.moveTo(qx,1):c.lineTo(qx,i%2===0?1:3)}c.stroke()} + c.restore()} +}, + +_dPl(c){ + const p=this.pl,x=p.x+TILE/2,y=p.y+TILE/2,r=TILE*.44; + c.save();c.translate(x,y);const dir=p.moving?p.dir:p.lastFace; + c.rotate({right:0,down:Math.PI/2,left:Math.PI,up:-Math.PI/2}[dir]||0); + if(this.sup){const g=.3+.2*Math.sin(this.fr*.2);c.shadowColor=CO.sup;c.shadowBlur=14;c.fillStyle=`rgba(0,221,255,${g})`;c.beginPath();c.arc(0,0,r+5,0,Math.PI*2);c.fill();c.shadowBlur=0} + const mo=p.mouth*.45;c.fillStyle=this.sup?'#FF9999':CO.kid;c.beginPath();c.arc(0,0,r,mo,Math.PI*2-mo,false);c.lineTo(0,0);c.closePath();c.fill(); + const ey=dir==='left'?r*.4:-r*.4; + c.fillStyle=CO.kE;c.beginPath();c.arc(-r*.1,ey,r*.18,0,Math.PI*2);c.fill(); + c.fillStyle='#000';c.beginPath();c.arc(-r*.05,ey,r*.08,0,Math.PI*2);c.fill();c.restore(); +}, + +_dFx(c){ + for(const f of this.fx){const pg=1-f.t/f.mx,al=1-pg;const hr=parseInt(f.co.slice(1,3),16),hg=parseInt(f.co.slice(3,5),16),hb=parseInt(f.co.slice(5,7),16); + if(f.tp==='glow'){c.fillStyle=`rgba(${hr},${hg},${hb},${al*.4})`;c.beginPath();c.arc(f.x,f.y,12+pg*25,0,Math.PI*2);c.fill()} + else if(f.tp==='exp'){for(let i=0;i<8;i++){const a=i/8*Math.PI*2,d=pg*28;c.fillStyle=`rgba(255,${0|120+135*(1-pg)},0,${al})`;c.fillRect(f.x+Math.cos(a)*d-2,f.y+Math.sin(a)*d-2,4,4)}} + else if(f.tp==='burst'){c.strokeStyle=`rgba(${hr},${hg},${hb},${al})`;c.lineWidth=2;c.beginPath();c.arc(f.x,f.y,pg*18,0,Math.PI*2);c.stroke()}} +}, + +_hud(c){ + c.font='10px "Press Start 2P",monospace';c.textBaseline='middle';const ty=HUD_TOP/2; + c.fillStyle=CO.hudT;c.textAlign='left';c.fillText('PUNKTE: '+(this.lsc+this.sc),8,ty); + c.textAlign='center';c.fillText('LEVEL '+this.lv,CW/2,ty-6); + if(this.sup){c.fillStyle=CO.sup;c.fillText('SUPER: '+Math.ceil(this.supT/FPS)+'s',CW/2,ty+8)} + const sec=0|this.lt/FPS;c.fillStyle=CO.hudT;c.textAlign='right';c.fillText('ZEIT: '+(0|sec/60)+':'+String(sec%60).padStart(2,'0'),CW-8,ty); + const by=CH-HUD_BOT/2; + for(let i=0;i{if(b.s!==a.s)return b.s-a.s;if(a.t!==b.t)return a.t-b.t;return a.d-b.d}); + this.rank=this.rank.slice(0,10);localStorage.setItem('ckr3',JSON.stringify(this.rank)); + let h=''; + this.rank.forEach((r,i)=>{const m=Math.floor(r.t/FPS/60),s=String(Math.floor(r.t/FPS%60)).padStart(2,'0');h+=``}); + document.getElementById('ranking-table-container').innerHTML=h+'
#NamePunkteZeit
${i+1}${r.n}${r.s}${m}:${s}
'; + document.getElementById('submitScoreBtn').style.display='none'; + document.getElementById('playerName').style.display='none'; + document.getElementById('playAgainBtn').style.display='inline-block'; +} + +}); diff --git a/Cyst_Kid/js/sound.js b/Cyst_Kid/js/sound.js new file mode 100644 index 0000000..6f51ccf --- /dev/null +++ b/Cyst_Kid/js/sound.js @@ -0,0 +1,12 @@ +// ========== SOUND ========== +class Snd{ + constructor(){this.x=null} + init(){if(this.x)return;try{this.x=new(window.AudioContext||window.webkitAudioContext)}catch(e){}} + _n(d,f1,f2,v,tp){if(!this.x)return;const n=this.x.currentTime,l=this.x.sampleRate*d,b=this.x.createBuffer(1,l,this.x.sampleRate),da=b.getChannelData(0);for(let i=0;isetTimeout(()=>this._t(f,null,.25,.12,'square'),i*120))} + over(){[400,350,300,200].forEach((f,i)=>setTimeout(()=>this._t(f,null,.25,.1,'sawtooth'),i*160))} +} diff --git a/Cyst_Kid/level-designer.html b/Cyst_Kid/level-designer.html new file mode 100644 index 0000000..c6dcbcc --- /dev/null +++ b/Cyst_Kid/level-designer.html @@ -0,0 +1,872 @@ + + + + + + Cyst-Kid — Level Designer + + + +

CYST-KID · LEVEL DESIGNER

+ +
+ +
+
+ + +
+
+ hover over a tile to inspect + +
+
+ + + +
+ + +
+ +
+ + + + + + diff --git a/Cyst_Kid/style.css b/Cyst_Kid/style.css new file mode 100644 index 0000000..7bf8cdd --- /dev/null +++ b/Cyst_Kid/style.css @@ -0,0 +1,264 @@ +/* ======================================== + CYST-KID - Arcade Style CSS + ======================================== */ + +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #0a0a2e; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + font-family: 'Press Start 2P', monospace; + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +#game-container { + position: relative; + display: flex; + justify-content: center; + align-items: center; +} + +#gameCanvas { + display: block; + image-rendering: pixelated; +} + +/* Overlay Screens */ +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; + background: rgba(10, 10, 46, 0.92); +} + +.overlay-content { + text-align: center; + color: #fff; + max-width: 500px; + padding: 20px; +} + +.game-title { + font-size: 48px; + color: #ff6b6b; + text-shadow: + 0 0 10px #ff6b6b, + 0 0 20px #ff4444, + 0 0 40px #cc0000, + 4px 4px 0px #8B0000; + margin-bottom: 30px; + letter-spacing: 6px; + animation: titlePulse 2s ease-in-out infinite; +} + +@keyframes titlePulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +.start-text { + font-size: 11px; + color: #aaddff; + margin-bottom: 20px; + line-height: 1.8; +} + +.controls-info { + font-size: 8px; + color: #7799bb; + margin-bottom: 30px; + line-height: 2.2; +} + +.arcade-btn { + font-family: 'Press Start 2P', monospace; + font-size: 14px; + padding: 15px 40px; + background: linear-gradient(180deg, #8B5CF6, #6D28D9); + color: #fff; + border: 3px solid #A78BFA; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 2px; + transition: all 0.15s; + margin: 8px; +} + +.arcade-btn:hover { + background: linear-gradient(180deg, #A78BFA, #8B5CF6); + transform: scale(1.05); + box-shadow: 0 0 20px rgba(139, 92, 246, 0.5); +} + +.arcade-btn:active { + transform: scale(0.95); +} + +.level-text { + font-size: 28px; + color: #FFD700; + text-shadow: 0 0 15px #FFD700; + margin-bottom: 20px; +} + +.gameover-text { + font-size: 32px; + color: #ff4444; + text-shadow: 0 0 15px #ff4444; + margin-bottom: 20px; + animation: shake 0.5s ease-in-out; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + +.lose-text, .win-msg { + font-size: 10px; + color: #ccc; + margin-bottom: 20px; + line-height: 2; +} + +.win-text { + font-size: 32px; + color: #00ff88; + text-shadow: 0 0 15px #00ff88; + margin-bottom: 20px; +} + +.name-input { + font-family: 'Press Start 2P', monospace; + font-size: 12px; + padding: 10px 20px; + background: #1a1a4e; + color: #fff; + border: 2px solid #8B5CF6; + border-radius: 4px; + text-align: center; + margin-bottom: 15px; + width: 250px; + outline: none; +} + +.name-input:focus { + border-color: #A78BFA; + box-shadow: 0 0 10px rgba(139, 92, 246, 0.4); +} + +#ranking-table-container { + margin-top: 20px; + max-height: 200px; + overflow-y: auto; +} + +#ranking-table-container table { + width: 100%; + border-collapse: collapse; + font-size: 8px; +} + +#ranking-table-container th { + color: #FFD700; + padding: 6px; + border-bottom: 1px solid #444; +} + +#ranking-table-container td { + color: #ccc; + padding: 5px; + border-bottom: 1px solid #222; +} + +#ranking-table-container tr:first-child td { + color: #FFD700; +} + +/* Mobile Joystick */ +#touch-controls { + display: none; + position: fixed; + bottom: 28px; + right: 28px; + z-index: 20; + touch-action: none; +} + +@media (pointer: coarse) { + #touch-controls { + display: block; + } +} + +#joystick-base { + width: 130px; + height: 130px; + border-radius: 50%; + background: rgba(139, 92, 246, 0.18); + border: 2px solid rgba(167, 139, 250, 0.50); + position: relative; + touch-action: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* Direction arrow hints */ +.joy-arrow { + position: absolute; + color: rgba(255, 255, 255, 0.45); + font-size: 13px; + line-height: 1; + pointer-events: none; + user-select: none; +} +.joy-up { top: 6px; left: 50%; transform: translateX(-50%); } +.joy-down { bottom: 6px; left: 50%; transform: translateX(-50%); } +.joy-left { left: 6px; top: 50%; transform: translateY(-50%); } +.joy-right { right: 6px; top: 50%; transform: translateY(-50%); } + +#joystick-knob { + width: 52px; + height: 52px; + border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #c4b5fd, #7c3aed); + border: 2px solid rgba(255, 255, 255, 0.6); + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + will-change: transform; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: #1a1a4e; +} +::-webkit-scrollbar-thumb { + background: #8B5CF6; + border-radius: 3px; +} diff --git a/WEB-INF/jboss-web.xml b/WEB-INF/jboss-web.xml new file mode 100644 index 0000000..cfbe0fb --- /dev/null +++ b/WEB-INF/jboss-web.xml @@ -0,0 +1,4 @@ + + + games + \ No newline at end of file diff --git a/WEB-INF/web.xml b/WEB-INF/web.xml new file mode 100644 index 0000000..0b45207 --- /dev/null +++ b/WEB-INF/web.xml @@ -0,0 +1,4 @@ + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..326812b --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + Kidney Lab — MW-56 Game & Watch + + + + +
+ + Cyst Kid +

+ Kidney Lab +

+
+ + + + diff --git a/kidney_lab/CLAUDE.md b/kidney_lab/CLAUDE.md new file mode 100644 index 0000000..ee3746c --- /dev/null +++ b/kidney_lab/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Running the game + +Open `index.html` directly in a browser — there is no build step, no bundler, and no package manager. All JS files are loaded as plain ` + + + + + + + + + + + diff --git a/kidney_lab/js/conveyor.js b/kidney_lab/js/conveyor.js new file mode 100644 index 0000000..0771655 --- /dev/null +++ b/kidney_lab/js/conveyor.js @@ -0,0 +1,200 @@ +/** + * conveyor.js + * Left conveyor belts (3×, goods travel → right). + * Right conveyor belt (1×, medication travels ← left). + * Pure logic — no rendering, no DOM. + */ + +/* ────────────────────────────── Good ──────────────────────────── */ + +const GoodState = Object.freeze({ + SLIDING: 'sliding', // moving along belt + READY: 'ready', // at belt's right end, waiting for pickup + COLLECTED: 'collected', // grabbed by player + DROPPED: 'dropped', // fell — penalty already applied +}); + +class Good { + constructor(beltRow, isSpecial) { + this.id = Good._nextId++; + this.beltRow = beltRow; + this.isSpecial = isSpecial; + this.x = SETTINGS.LEFT_ZONE_START + SETTINGS.GOOD_SIZE / 2; + this.y = SETTINGS.BELT_ROWS[beltRow]; + this.state = GoodState.SLIDING; + this.waitTimer = 0; + } +} +Good._nextId = 0; + +/* ────────────────────────── LeftConveyorSystem ─────────────────── */ + +class LeftConveyorSystem { + constructor() { + // Belt strip animation offset (px, wraps at 40) + this.animOffset = 0; + + // Per-belt state: goods array + countdown to next spawn + this.belts = SETTINGS.BELT_INIT_OFFSETS.map(offset => ({ + goods: [], + spawnTimer: offset, + })); + } + + _randomSpawnTime() { + return SETTINGS.BELT_MIN_SPAWN + + Math.random() * (SETTINGS.BELT_MAX_SPAWN - SETTINGS.BELT_MIN_SPAWN); + } + + /** + * Update all belts. + * @param {number} dt – delta time in ms + * @param {function} onDrop – called with (good) when a good drops off + */ + update(dt, onDrop) { + this.animOffset = (this.animOffset + SETTINGS.LEFT_BELT_SPEED * dt / 1000) % 40; + + this.belts.forEach((belt, row) => { + // ── Spawn ── + belt.spawnTimer -= dt; + if (belt.spawnTimer <= 0) { + belt.spawnTimer = this._randomSpawnTime(); + const isSpecial = Math.random() < SETTINGS.SPECIAL_GOOD_CHANCE; + belt.goods.push(new Good(row, isSpecial)); + } + + // ── Move & age goods ── + belt.goods.forEach(good => { + if (good.state === GoodState.SLIDING) { + good.x += SETTINGS.LEFT_BELT_SPEED * dt / 1000; + const pickupX = SETTINGS.LEFT_ZONE_END - SETTINGS.GOOD_SIZE * 0.5; + if (good.x >= pickupX) { + good.x = pickupX; + good.state = GoodState.READY; + good.waitTimer = SETTINGS.GOOD_WAIT_TIME; + } + } else if (good.state === GoodState.READY) { + good.waitTimer -= dt; + if (good.waitTimer <= 0) { + good.state = GoodState.DROPPED; + onDrop(good); + } + } + }); + + // ── Prune finished goods ── + belt.goods = belt.goods.filter( + g => g.state === GoodState.SLIDING || g.state === GoodState.READY + ); + }); + } + + /** + * Try to collect the READY good on the given row. + * Returns the Good object on success, null otherwise. + */ + tryCollect(row) { + const belt = this.belts[row]; + const good = belt.goods.find(g => g.state === GoodState.READY); + if (!good) return null; + good.state = GoodState.COLLECTED; + return good; + } + + /** True if belt[row] has a READY good waiting at the pickup point. */ + hasReadyGood(row) { + return this.belts[row].goods.some(g => g.state === GoodState.READY); + } + + /** All goods in SLIDING or READY state across all belts (for rendering). */ + allGoods() { + return this.belts.flatMap(b => b.goods); + } +} + +/* ─────────────────────── Medication (right belt) ──────────────── */ + +const MedState = Object.freeze({ + ON_BELT: 'on_belt', // sliding left + READY: 'ready', // at left end, waiting for player + PICKED: 'picked', // player has it + EXPIRED: 'expired', // timed out while waiting +}); + +class Medication { + constructor() { + this.id = Medication._nextId++; + // Start at right edge of right zone + this.x = SETTINGS.RIGHT_ZONE_END - SETTINGS.GOOD_SIZE; + this.y = SETTINGS.BELT_ROWS[1]; // always middle row + this.state = MedState.ON_BELT; + this.waitTimer = 0; + } +} +Medication._nextId = 0; + +/* ────────────────────────── RightConveyorSystem ────────────────── */ + +class RightConveyorSystem { + constructor() { + this.animOffset = 0; + this.medications = []; + } + + addMedication() { + this.medications.push(new Medication()); + } + + /** + * Update right belt medications. + * @param {number} dt – delta time ms + */ + update(dt) { + this.animOffset = (this.animOffset + SETTINGS.RIGHT_BELT_SPEED * dt / 1000) % 40; + + this.medications.forEach(med => { + if (med.state === MedState.ON_BELT) { + med.x -= SETTINGS.RIGHT_BELT_SPEED * dt / 1000; + const pickupX = SETTINGS.RIGHT_ZONE_START + SETTINGS.GOOD_SIZE * 0.5; + if (med.x <= pickupX) { + med.x = pickupX; + med.state = MedState.READY; + med.waitTimer = SETTINGS.MED_WAIT_TIME; + } + } else if (med.state === MedState.READY) { + med.waitTimer -= dt; + if (med.waitTimer <= 0) { + med.state = MedState.EXPIRED; + } + } + }); + + // Prune expired / picked medications + this.medications = this.medications.filter( + m => m.state === MedState.ON_BELT || m.state === MedState.READY + ); + } + + /** True if there is a READY medication at the pickup point (row 1 left end). */ + hasReadyMedication() { + return this.medications.some(m => m.state === MedState.READY); + } + + /** + * Try to pick up the READY medication. + * Returns true on success. + */ + tryPickup() { + const med = this.medications.find(m => m.state === MedState.READY); + if (!med) return false; + med.state = MedState.PICKED; + return true; + } + + /** Visible medications for rendering (ON_BELT and READY). */ + visibleMedications() { + return this.medications.filter( + m => m.state === MedState.ON_BELT || m.state === MedState.READY + ); + } +} diff --git a/kidney_lab/js/game.js b/kidney_lab/js/game.js new file mode 100644 index 0000000..36873df --- /dev/null +++ b/kidney_lab/js/game.js @@ -0,0 +1,1080 @@ +/** + * game.js + * Main game controller: initialisation, game loop, input handling, + * automatic player interactions, and all canvas rendering. + * Supports 1-player and 2-player (mirrored split-screen) modes. + */ + +const Game = (() => { + + /* ── Private state ──────────────────────────────────────────── */ + let canvas, ctx; + let running, lastTimestamp, animFrame; + let timeLeft; + let playerCount = 1; + + // Per-player arrays (index 0 = P1, index 1 = P2) + let gPlayers = []; // Player instances + let gLeftBelts = []; // LeftConveyorSystem instances + let gRightBelts = []; // RightConveyorSystem instances + let gLabs = []; // Lab instances + let gScores = []; // numbers + let gPopups = []; // score-popup arrays + let gHappyTimers = []; // patient happy timers + let gNames = []; // player name strings + + /* ── Active render-state proxy ──────────────────────────────── */ + // Set _pIdx before each render pass to pick the right player's data. + let _pIdx = 0; + let _mirrored = false; + + const RS = { + get player() { return gPlayers[_pIdx]; }, + get leftBelts() { return gLeftBelts[_pIdx]; }, + get rightBelt() { return gRightBelts[_pIdx]; }, + get lab() { return gLabs[_pIdx]; }, + get score() { return gScores[_pIdx]; }, + get popups() { return gPopups[_pIdx]; }, + get happyTimer() { return gHappyTimers[_pIdx]; }, + get name() { return gNames[_pIdx]; }, + }; + + /** + * ft — fillText that reads correctly even inside a mirrored ctx transform. + * When _mirrored is true the outer ctx has scale(-1,1) applied; we + * double-flip locally so the glyph is drawn right-way-round at the + * correct logical position. + */ + function ft(text, x, y) { + if (!_mirrored) { ctx.fillText(text, x, y); return; } + ctx.save(); + ctx.translate(x, y); + ctx.scale(-1, 1); + ctx.fillText(text, 0, 0); + ctx.restore(); + } + + /* ── Input ──────────────────────────────────────────────────── */ + let keys = {}; + let keyHandlerDown, keyHandlerUp; + + function onKeyDown(e) { + if (!running) return; + if (keys[e.code]) return; // suppress key-repeat for movement + keys[e.code] = true; + + // P1: arrow keys (always) + switch (e.code) { + case 'ArrowUp': e.preventDefault(); gPlayers[0].move('up'); break; + case 'ArrowDown': e.preventDefault(); gPlayers[0].move('down'); break; + case 'ArrowLeft': e.preventDefault(); gPlayers[0].move('left'); break; + case 'ArrowRight': e.preventDefault(); gPlayers[0].move('right'); break; + } + + if (playerCount === 1) { + // 1P: WASD also drives P1 + switch (e.code) { + case 'KeyW': e.preventDefault(); gPlayers[0].move('up'); break; + case 'KeyS': e.preventDefault(); gPlayers[0].move('down'); break; + case 'KeyA': e.preventDefault(); gPlayers[0].move('left'); break; + case 'KeyD': e.preventDefault(); gPlayers[0].move('right'); break; + } + } else { + // 2P: WASD drives P2. Left/right are swapped because P2's side is + // mirrored — pressing visual-right (D) means moving left in game coords. + switch (e.code) { + case 'KeyW': e.preventDefault(); gPlayers[1].move('up'); break; + case 'KeyS': e.preventDefault(); gPlayers[1].move('down'); break; + case 'KeyA': e.preventDefault(); gPlayers[1].move('right'); break; + case 'KeyD': e.preventDefault(); gPlayers[1].move('left'); break; + } + } + } + + function onKeyUp(e) { keys[e.code] = false; } + + /* ── Scoring helpers (per-player index) ─────────────────────── */ + function addScoreFor(pIdx, delta, px, py) { + gScores[pIdx] += delta; + const sign = delta >= 0 ? '+' : ''; + const color = delta >= 0 ? '#ffdd44' : '#ff4455'; + gPopups[pIdx].push({ x: px, y: py, text: sign + delta, life: 900, color }); + } + + function dropGoodFor(pIdx, good) { + const penalty = good.isSpecial ? SETTINGS.POINTS_DROP_SPECIAL : SETTINGS.POINTS_DROP_GOOD; + addScoreFor(pIdx, penalty, SETTINGS.LEFT_ZONE_END - 20, SETTINGS.BELT_ROWS[good.beltRow]); + } + + /* ── Auto-interaction (position-based, per player) ──────────── */ + function handleInteractionsFor(pIdx) { + const p = gPlayers[pIdx]; + const lb = gLeftBelts[pIdx]; + const rb = gRightBelts[pIdx]; + const l = gLabs[pIdx]; + const { col, row } = p; + + // Pickup from left belt (col 0) + if (col === 0 && lb.hasReadyGood(row)) { + const good = lb.tryCollect(row); + if (good) { + if (!p.addGood(good)) dropGoodFor(pIdx, good); + } + } + + // Right-zone interactions (rightmost col) + if (col === SETTINGS.PLAYER_COLS - 1) { + if (row === 2 && p.goods.length > 0) { + const goods = p.depositGoods(); + addScoreFor(pIdx, goods.length * SETTINGS.POINTS_GOOD_LAB, p.pixelX(), p.pixelY() - 30); + l.depositGoods(goods, () => rb.addMedication()); + } + if (row === 1 && !p.hasMedication && rb.hasReadyMedication()) { + rb.tryPickup(); + p.pickupMedication(); + } + if (row === 0 && p.hasMedication) { + if (p.deliverMedication()) { + addScoreFor(pIdx, SETTINGS.POINTS_SPECIAL_PATIENT, p.pixelX(), p.pixelY() - 30); + gHappyTimers[pIdx] = SETTINGS.PATIENT_HAPPY_DURATION; + } + } + } + } + + /* ── Update ─────────────────────────────────────────────────── */ + function update(dt) { + for (let i = 0; i < playerCount; i++) { + gPlayers[i].update(dt); + gLeftBelts[i].update(dt, (g) => dropGoodFor(i, g)); + gRightBelts[i].update(dt); + gLabs[i].update(dt); + handleInteractionsFor(i); + gPopups[i].forEach(p => { p.life -= dt; p.y -= dt * 0.04; }); + gPopups[i] = gPopups[i].filter(p => p.life > 0); + if (gHappyTimers[i] > 0) gHappyTimers[i] -= dt; + } + + timeLeft -= dt; + if (timeLeft <= 0) { timeLeft = 0; endGame(); } + } + + /* ── End game ───────────────────────────────────────────────── */ + function endGame() { + running = false; + cancelAnimationFrame(animFrame); + document.removeEventListener('keydown', keyHandlerDown); + document.removeEventListener('keyup', keyHandlerUp); + HighScoreScreen.show(gNames, gScores, playerCount); + } + + /* ════════════════════════════ RENDERING ════════════════════════ */ + + /* ── Utility draw helpers ───────────────────────────────────── */ + function roundRect(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } + + function drawBeltStripes(animOff, x, y, w, h, colorA, colorB, leftToRight) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + + const stripeW = 20; + const off = leftToRight + ? animOff % stripeW + : (stripeW - (animOff % stripeW)) % stripeW; + + ctx.fillStyle = colorA; + ctx.fillRect(x, y, w, h); + + ctx.fillStyle = colorB; + for (let sx = x - stripeW * 2 + off; sx < x + w + stripeW; sx += stripeW * 2) { + ctx.beginPath(); + ctx.moveTo(sx, y); + ctx.lineTo(sx + stripeW, y); + ctx.lineTo(sx + stripeW - 6, y + h); + ctx.lineTo(sx - 6, y + h); + ctx.fill(); + } + ctx.restore(); + } + + function drawGood(good) { + const s = SETTINGS.GOOD_SIZE; + const gx = good.x; + const gy = good.y - SETTINGS.BELT_HEIGHT / 2 - s / 2; + drawKidney(gx, gy, s); + } + + function drawKidney(cx, cy, size) { + const w = size * 0.95; + const h = size * 0.72; + const hw = w / 2; + const hh = h / 2; + + ctx.save(); + + ctx.beginPath(); + ctx.moveTo(cx - hw * 0.1, cy - hh); + ctx.bezierCurveTo(cx + hw * 0.6, cy - hh * 1.1, cx + hw, cy - hh * 0.3, cx + hw, cy + hh * 0.1); + ctx.bezierCurveTo(cx + hw, cy + hh * 1.1, cx - hw * 0.1, cy + hh, cx - hw * 0.5, cy + hh * 0.6); + ctx.bezierCurveTo(cx - hw * 1.05, cy + hh * 0.2, cx - hw * 0.9, cy - hh * 0.35, cx - hw * 0.45, cy - hh * 0.1); + ctx.bezierCurveTo(cx - hw * 0.15, cy - hh * 0.4, cx - hw * 0.4, cy - hh, cx - hw * 0.1, cy - hh); + ctx.closePath(); + + ctx.shadowColor = 'rgba(0,0,0,0.4)'; + ctx.shadowBlur = 4; + ctx.shadowOffsetY = 2; + ctx.fillStyle = '#c0392b'; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + + ctx.strokeStyle = '#7b1a12'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(cx + hw * 0.05, cy - hh * 0.65); + ctx.bezierCurveTo(cx + hw * 0.55, cy - hh * 0.75, cx + hw * 0.75, cy - hh * 0.15, cx + hw * 0.6, cy + hh * 0.1); + ctx.strokeStyle = 'rgba(255,160,140,0.55)'; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.stroke(); + + ctx.beginPath(); + ctx.ellipse(cx - hw * 0.05, cy + hh * 0.05, hw * 0.22, hh * 0.28, -0.3, 0, Math.PI * 2); + ctx.fillStyle = '#e8b4a8'; + ctx.fill(); + ctx.strokeStyle = '#b5483a'; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.restore(); + } + + function drawMedication(med) { + const s = SETTINGS.GOOD_SIZE; + const hs = s / 2; + const mx = med.x; + const my = med.y - SETTINGS.BELT_HEIGHT / 2 - hs; + + ctx.shadowColor = SETTINGS.COLOR_MED; + ctx.shadowBlur = 14; + + roundRect(ctx, mx - hs + 2, my - hs + 3, s - 4, s - 3, 6); + ctx.fillStyle = SETTINGS.COLOR_MED; + ctx.fill(); + ctx.strokeStyle = SETTINGS.COLOR_MED_OUT; + ctx.lineWidth = 2; + ctx.stroke(); + + roundRect(ctx, mx - 5, my - hs - 2, 10, 7, 3); + ctx.fillStyle = '#ffffff'; + ctx.fill(); + ctx.strokeStyle = SETTINGS.COLOR_MED_OUT; + ctx.lineWidth = 1.5; + ctx.stroke(); + + ctx.strokeStyle = 'rgba(0,80,40,0.8)'; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(mx, my - 2); + ctx.lineTo(mx, my + 6); + ctx.moveTo(mx - 4, my + 2); + ctx.lineTo(mx + 4, my + 2); + ctx.stroke(); + + ctx.shadowBlur = 0; + ctx.lineCap = 'butt'; + } + + /* ── Header — 1P ────────────────────────────────────────────── */ + function renderHeader() { + const W = SETTINGS.CANVAS_WIDTH; + const H = SETTINGS.HEADER_HEIGHT; + + ctx.fillStyle = SETTINGS.COLOR_HEADER; + ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = SETTINGS.COLOR_HEADER_LINE; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(W, H); ctx.stroke(); + + // Score + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText('SCORE', 24, H / 2 - 8); + ctx.fillStyle = SETTINGS.COLOR_SCORE_VAL; + ctx.font = 'bold 22px "Courier New", monospace'; + ctx.fillText(Math.max(0, RS.score).toString().padStart(5, '0'), 24, H / 2 + 10); + + // Player name + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.fillText('PLAYER', W / 2, H / 2 - 8); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 18px "Courier New", monospace'; + ctx.fillText(RS.name.toUpperCase().slice(0, 12), W / 2, H / 2 + 10); + + // Timer + const secLeft = Math.ceil(timeLeft / 1000); + const timerStr = `${Math.floor(secLeft / 60)}:${(secLeft % 60).toString().padStart(2, '0')}`; + const tColor = secLeft > 60 ? SETTINGS.COLOR_TIMER_OK + : secLeft > 20 ? SETTINGS.COLOR_TIMER_WARN + : SETTINGS.COLOR_TIMER_DANGER; + + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'right'; + ctx.fillText('TIME', W - 24, H / 2 - 8); + ctx.fillStyle = tColor; + ctx.font = 'bold 22px "Courier New", monospace'; + ctx.fillText(timerStr, W - 24, H / 2 + 10); + } + + /* ── Header — 2P (full-width, both players) ─────────────────── */ + function renderHeader2P() { + const W = SETTINGS.CANVAS_WIDTH; + const TW = W * 2; + const H = SETTINGS.HEADER_HEIGHT; + + ctx.fillStyle = SETTINGS.COLOR_HEADER; + ctx.fillRect(0, 0, TW, H); + ctx.strokeStyle = SETTINGS.COLOR_HEADER_LINE; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(TW, H); ctx.stroke(); + + // Centre divider line + ctx.strokeStyle = 'rgba(80,120,180,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(W, 0); ctx.lineTo(W, H); ctx.stroke(); + + const secLeft = Math.ceil(timeLeft / 1000); + const timerStr = `${Math.floor(secLeft / 60)}:${(secLeft % 60).toString().padStart(2, '0')}`; + const tColor = secLeft > 60 ? SETTINGS.COLOR_TIMER_OK + : secLeft > 20 ? SETTINGS.COLOR_TIMER_WARN + : SETTINGS.COLOR_TIMER_DANGER; + + // Shared timer in the centre + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('TIME', TW / 2, H / 2 - 8); + ctx.fillStyle = tColor; + ctx.font = 'bold 22px "Courier New", monospace'; + ctx.fillText(timerStr, TW / 2, H / 2 + 10); + + // P2 — left half + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'left'; + ctx.fillText('SCORE', 24, H / 2 - 8); + ctx.fillStyle = SETTINGS.COLOR_SCORE_VAL; + ctx.font = 'bold 22px "Courier New", monospace'; + ctx.fillText(Math.max(0, gScores[1]).toString().padStart(5, '0'), 24, H / 2 + 10); + + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.fillText('P2', W / 2, H / 2 - 8); + ctx.fillStyle = '#aaddff'; + ctx.font = 'bold 18px "Courier New", monospace'; + ctx.fillText(gNames[1].toUpperCase().slice(0, 12), W / 2, H / 2 + 10); + + // P1 — right half + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'right'; + ctx.fillText('SCORE', TW - 24, H / 2 - 8); + ctx.fillStyle = SETTINGS.COLOR_SCORE_VAL; + ctx.font = 'bold 22px "Courier New", monospace'; + ctx.fillText(Math.max(0, gScores[0]).toString().padStart(5, '0'), TW - 24, H / 2 + 10); + + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '13px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.fillText('P1', W + W / 2, H / 2 - 8); + ctx.fillStyle = '#aaddff'; + ctx.font = 'bold 18px "Courier New", monospace'; + ctx.fillText(gNames[0].toUpperCase().slice(0, 12), W + W / 2, H / 2 + 10); + } + + /* ── Left zone ──────────────────────────────────────────────── */ + function renderLeftZone() { + const top = SETTINGS.HEADER_HEIGHT; + const bot = SETTINGS.CANVAS_HEIGHT; + const zoneW = SETTINGS.LEFT_ZONE_END; + const beltH = SETTINGS.BELT_HEIGHT; + + ctx.fillStyle = SETTINGS.COLOR_BG; + ctx.fillRect(0, top, zoneW, bot - top); + + ctx.fillStyle = 'rgba(40,80,120,0.35)'; + ctx.font = 'bold 11px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ft('SUPPLY', zoneW / 2, top + 6); + + SETTINGS.BELT_ROWS.forEach((cy, row) => { + const by = cy - beltH / 2; + + drawBeltStripes(RS.leftBelts.animOffset, 0, by, zoneW, beltH, + SETTINGS.COLOR_BELT, SETTINGS.COLOR_BELT_STRIPE, true); + + ctx.fillStyle = SETTINGS.COLOR_BELT_EDGE; + ctx.fillRect(0, by - 4, zoneW, 4); + ctx.fillRect(0, by + beltH, zoneW, 4); + + ctx.fillStyle = 'rgba(136,200,255,0.4)'; + ctx.font = '10px "Courier New", monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ft(`B${row + 1}`, 6, cy); + + if (RS.leftBelts.hasReadyGood(row)) { + ctx.fillStyle = '#ffdd44'; + ctx.font = 'bold 14px monospace'; + ctx.textAlign = 'right'; + ft('►', zoneW - 4, cy); + } + }); + + RS.leftBelts.allGoods().forEach(good => { + if (good.state === GoodState.SLIDING || good.state === GoodState.READY) { + drawGood(good); + } + }); + } + + /* ── Middle zone ────────────────────────────────────────────── */ + function renderMiddleZone() { + const zx = SETTINGS.MIDDLE_ZONE_START; + const zw = SETTINGS.MIDDLE_ZONE_END - SETTINGS.MIDDLE_ZONE_START; + const top = SETTINGS.HEADER_HEIGHT; + const bot = SETTINGS.CANVAS_HEIGHT; + + ctx.fillStyle = '#0c1e34'; + ctx.fillRect(zx, top, zw, bot - top); + + ctx.fillStyle = 'rgba(40,80,120,0.35)'; + ctx.font = 'bold 11px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ft('COLLECTION', zx + zw / 2, top + 6); + + ctx.strokeStyle = 'rgba(30,60,100,0.5)'; + ctx.lineWidth = 1; + SETTINGS.BELT_ROWS.forEach(cy => { + ctx.beginPath(); + ctx.setLineDash([4, 6]); + ctx.moveTo(zx, cy); + ctx.lineTo(zx + zw, cy); + ctx.stroke(); + }); + ctx.setLineDash([]); + } + + /* ── Player ─────────────────────────────────────────────────── */ + function renderPlayer() { + const px = RS.player.pixelX(); + const py = RS.player.pixelY(); + + const isWalking = RS.player.moveCooldown > 0; + const phase = RS.player.walkPhase; + const legSwing = isWalking ? Math.sin(phase) * 12 : 0; + const armSwing = isWalking ? -Math.sin(phase) * 10 : 0; + const bob = isWalking ? Math.abs(Math.sin(phase)) * 2 : 0; + const flash = RS.player.interactFlash > 0; + + const groundY = py + 16; + const coatHem = groundY - 6; + const waistY = groundY - 17; + const torsoTop = waistY - 22 - bob; + const headCY = torsoTop - 14; + const headR = 11; + const skinColor = '#f5c99a'; + const coatColor = flash ? '#ddf4ff' : '#f0f4ff'; + + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Shadow + ctx.globalAlpha = 0.25; + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.ellipse(px, groundY + 5, 17, 5, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + + // Trousers + ctx.lineWidth = 9; + ctx.strokeStyle = '#1e2d4a'; + ctx.beginPath(); ctx.moveTo(px - 5, waistY); ctx.lineTo(px - 5 + legSwing, coatHem + 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px + 5, waistY); ctx.lineTo(px + 5 - legSwing, coatHem + 1); ctx.stroke(); + + // Shoes + ctx.fillStyle = '#111'; + ctx.beginPath(); ctx.ellipse(px - 5 + legSwing + 2, groundY, 9, 5, 0.08, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.ellipse(px + 5 - legSwing - 2, groundY, 9, 5, -0.08, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.beginPath(); ctx.ellipse(px - 5 + legSwing, groundY - 2, 5, 2, 0, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.ellipse(px + 5 - legSwing, groundY - 2, 5, 2, 0, 0, Math.PI * 2); ctx.fill(); + + // White coat body + roundRect(ctx, px - 13, torsoTop, 26, coatHem - torsoTop, 5); + ctx.fillStyle = coatColor; + ctx.fill(); + ctx.strokeStyle = '#c8d4e8'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Lapels / scrubs + ctx.fillStyle = '#7ab8d4'; + ctx.beginPath(); + ctx.moveTo(px - 6, torsoTop + 2); + ctx.lineTo(px, torsoTop + 14); + ctx.lineTo(px + 6, torsoTop + 2); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(px - 13, torsoTop + 4); ctx.lineTo(px - 3, torsoTop + 16); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px + 13, torsoTop + 4); ctx.lineTo(px + 3, torsoTop + 16); ctx.stroke(); + + // Button line + ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + ctx.beginPath(); ctx.moveTo(px, torsoTop + 16); ctx.lineTo(px, coatHem - 2); ctx.stroke(); + ctx.setLineDash([]); + + // Name badge + roundRect(ctx, px - 12, torsoTop + 18, 14, 9, 2); + ctx.fillStyle = 'white'; ctx.fill(); + ctx.strokeStyle = '#aabbcc'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = '#336699'; + ctx.font = 'bold 5px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ft('DR.', px - 11, torsoTop + 22.5); + + // Red cross + ctx.fillStyle = '#cc2222'; + ctx.fillRect(px + 6, torsoTop + 19, 6, 2); + ctx.fillRect(px + 8, torsoTop + 17, 2, 6); + + // Left sleeve + ctx.strokeStyle = coatColor; ctx.lineWidth = 8; + ctx.beginPath(); ctx.moveTo(px - 12, torsoTop + 5); ctx.lineTo(px - 16 + armSwing, torsoTop + 20); ctx.stroke(); + ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(px - 12, torsoTop + 5); ctx.lineTo(px - 16 + armSwing, torsoTop + 20); ctx.stroke(); + // Right sleeve + ctx.strokeStyle = coatColor; ctx.lineWidth = 8; + ctx.beginPath(); ctx.moveTo(px + 12, torsoTop + 5); ctx.lineTo(px + 16 - armSwing, torsoTop + 20); ctx.stroke(); + ctx.strokeStyle = '#c8d4e8'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(px + 12, torsoTop + 5); ctx.lineTo(px + 16 - armSwing, torsoTop + 20); ctx.stroke(); + + // Forearms + ctx.strokeStyle = skinColor; ctx.lineWidth = 6; + ctx.beginPath(); ctx.moveTo(px - 16 + armSwing, torsoTop + 20); ctx.lineTo(px - 17 + armSwing, torsoTop + 28); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px + 16 - armSwing, torsoTop + 20); ctx.lineTo(px + 17 - armSwing, torsoTop + 28); ctx.stroke(); + + // Hands + ctx.fillStyle = skinColor; + ctx.beginPath(); ctx.arc(px - 17 + armSwing, torsoTop + 31, 4.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(px + 17 - armSwing, torsoTop + 31, 4.5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#d4956a'; ctx.lineWidth = 1; ctx.stroke(); + + // Stethoscope + ctx.fillStyle = '#aaaaaa'; + ctx.beginPath(); ctx.arc(px - 7, torsoTop + 4, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(px + 7, torsoTop + 4, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#888'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(px - 7, torsoTop + 6); + ctx.bezierCurveTo(px - 7, torsoTop + 26, px + 7, torsoTop + 26, px + 7, torsoTop + 6); + ctx.stroke(); + ctx.fillStyle = '#bbb'; ctx.strokeStyle = '#888'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(px, torsoTop + 26, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = '#ddd'; + ctx.beginPath(); ctx.arc(px, torsoTop + 26, 2, 0, Math.PI * 2); ctx.fill(); + + // Neck + ctx.fillStyle = skinColor; + ctx.beginPath(); ctx.rect(px - 4, headCY + headR - 4, 8, 8); ctx.fill(); + + // Head + ctx.beginPath(); ctx.arc(px, headCY, headR, 0, Math.PI * 2); + ctx.fillStyle = skinColor; ctx.fill(); + ctx.strokeStyle = '#d4956a'; ctx.lineWidth = 1; ctx.stroke(); + + // Hair + ctx.fillStyle = '#3d2200'; + ctx.beginPath(); ctx.arc(px, headCY, headR, Math.PI, 0); ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.arc(px - headR + 1, headCY, headR * 0.5, Math.PI * 0.6, Math.PI * 1.3); ctx.fill(); + + // Ears + ctx.fillStyle = skinColor; ctx.strokeStyle = '#d4956a'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.ellipse(px - headR, headCY + 2, 3.5, 5, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.beginPath(); ctx.ellipse(px + headR, headCY + 2, 3.5, 5, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + + // Glasses + ctx.strokeStyle = '#445566'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.rect(px - 10, headCY, 8, 6); ctx.stroke(); + ctx.beginPath(); ctx.rect(px + 2, headCY, 8, 6); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px - 2, headCY + 3); ctx.lineTo(px + 2, headCY + 3); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px - 10, headCY + 3); ctx.lineTo(px - headR - 1, headCY + 3); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px + 10, headCY + 3); ctx.lineTo(px + headR + 1, headCY + 3); ctx.stroke(); + + // Eyes + ctx.fillStyle = '#224466'; + ctx.beginPath(); ctx.arc(px - 6, headCY + 3, 2, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(px + 6, headCY + 3, 2, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.beginPath(); ctx.arc(px - 5, headCY + 2, 0.9, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(px + 7, headCY + 2, 0.9, 0, Math.PI * 2); ctx.fill(); + + // Nose + ctx.fillStyle = '#d4956a'; + ctx.beginPath(); ctx.arc(px, headCY + 7, 2, 0, Math.PI * 2); ctx.fill(); + + // Smile + ctx.strokeStyle = '#a06040'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(px, headCY + 10, 4, 0.15, Math.PI - 0.15); ctx.stroke(); + + ctx.restore(); + + // Carried kidneys — small icons above head + if (RS.player.goods.length > 0) { + const iconSize = 11; + const gap = 14; + const totalW = (RS.player.goods.length - 1) * gap; + const sx = px - totalW / 2; + const iy = headCY - headR - 10; + RS.player.goods.forEach((_, i) => drawKidney(sx + i * gap, iy, iconSize)); + } + + // Medication icon above head + if (RS.player.hasMedication) { + const iy = headCY - headR - (RS.player.goods.length > 0 ? 24 : 10); + ctx.shadowColor = SETTINGS.COLOR_MED; + ctx.shadowBlur = 10; + ctx.font = '14px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ft('💊', px, iy); + ctx.shadowBlur = 0; + } + } + + /* ── Right zone ─────────────────────────────────────────────── */ + function renderRightZone() { + const zx = SETTINGS.RIGHT_ZONE_START; + const zw = SETTINGS.RIGHT_ZONE_END - SETTINGS.RIGHT_ZONE_START; + const top = SETTINGS.HEADER_HEIGHT; + const bot = SETTINGS.CANVAS_HEIGHT; + const cx = zx + zw / 2; + + ctx.fillStyle = '#0b1c30'; + ctx.fillRect(zx, top, zw, bot - top); + + ctx.fillStyle = 'rgba(40,80,120,0.35)'; + ctx.font = 'bold 11px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ft('HOSPITAL', cx, top + 6); + + // ── Patient ────────────────────────────────────────────────── + const py0 = SETTINGS.BELT_ROWS[0]; + const happy = RS.happyTimer > 0; + + roundRect(ctx, zx + 20, py0 - 50, zw - 40, 80, 10); + ctx.fillStyle = happy ? 'rgba(80,60,10,0.4)' : 'rgba(60,20,10,0.4)'; + ctx.fill(); + ctx.strokeStyle = happy ? SETTINGS.COLOR_PATIENT_HAPPY : SETTINGS.COLOR_PATIENT_DARK; + ctx.lineWidth = 2; ctx.stroke(); + + const pColor = happy ? SETTINGS.COLOR_PATIENT_HAPPY : SETTINGS.COLOR_PATIENT; + roundRect(ctx, cx - 14, py0 - 20, 28, 26, 5); + ctx.fillStyle = '#eeeeee'; ctx.fill(); + ctx.strokeStyle = '#cccccc'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.beginPath(); ctx.arc(cx, py0 - 28, 16, 0, Math.PI * 2); + ctx.fillStyle = pColor; ctx.fill(); + ctx.strokeStyle = SETTINGS.COLOR_PATIENT_DARK; ctx.lineWidth = 2; ctx.stroke(); + + ctx.fillStyle = '#222'; + if (happy) { + ctx.beginPath(); ctx.arc(cx, py0 - 30, 7, 0, Math.PI); + ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx - 5, py0 - 32, 2, 0, Math.PI * 2); + ctx.arc(cx + 5, py0 - 32, 2, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.beginPath(); ctx.arc(cx, py0 - 22, 5, Math.PI, 0); + ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = '#ff6644'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ft('~', cx - 18, py0 - 32); + ft('~', cx + 18, py0 - 32); + } + + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '11px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ft('PATIENT', cx, py0 + 32); + + // ── Right belt ─────────────────────────────────────────────── + const py1 = SETTINGS.BELT_ROWS[1]; + const beltH = SETTINGS.BELT_HEIGHT; + const by = py1 - beltH / 2; + + drawBeltStripes(RS.rightBelt.animOffset, zx, by, zw, beltH, + SETTINGS.COLOR_BELT_R, SETTINGS.COLOR_BELT_STRIPE_R, false); + + ctx.fillStyle = SETTINGS.COLOR_BELT_EDGE; + ctx.fillRect(zx, by - 4, zw, 4); + ctx.fillRect(zx, by + beltH, zw, 4); + + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '11px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ft('◄ MEDICATION BELT', cx, by + beltH + 8); + + RS.rightBelt.visibleMedications().forEach(drawMedication); + + // ── Laboratory ─────────────────────────────────────────────── + const py2 = SETTINGS.BELT_ROWS[2]; + const labW = zw - 30; + const labH = 70; + const lx = zx + 15; + const ly = py2 - labH / 2; + + const flash = RS.lab.isFlashing(); + const labColor = flash ? 'rgba(10,100,50,0.7)' : SETTINGS.COLOR_LAB; + + roundRect(ctx, lx, ly, labW, labH, 10); + ctx.fillStyle = labColor; ctx.fill(); + ctx.strokeStyle = flash ? SETTINGS.COLOR_LAB_OUTLINE : SETTINGS.COLOR_LAB_LIGHT; + ctx.lineWidth = flash ? 3 : 2; ctx.stroke(); + + ctx.fillStyle = '#aaffcc'; + ctx.font = 'bold 13px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ft('LABORATORY', cx, ly + 18); + + if (RS.lab.isAnalyzing()) { + const prog = RS.lab.analyzeProgress(); + const bw = labW - 20; + const bx = lx + 10; + const bby = ly + 36; + + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + roundRect(ctx, bx, bby, bw, 12, 4); ctx.fill(); + + ctx.fillStyle = SETTINGS.COLOR_MED; + roundRect(ctx, bx, bby, bw * prog, 12, 4); ctx.fill(); + + ctx.fillStyle = '#aaffcc'; + ctx.font = '10px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ft('ANALYSING...', cx, bby + 6); + + ctx.textBaseline = 'top'; + ft(`Kidneys: ${RS.lab.totalProcessed}`, cx, ly + 52); + } else { + ctx.fillStyle = '#aaffcc'; + ctx.font = '11px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ft(`Processed: ${RS.lab.totalProcessed}`, cx, ly + 36); + } + } + + /* ── Score popups ───────────────────────────────────────────── */ + function renderPopups() { + RS.popups.forEach(p => { + const alpha = Math.min(1, p.life / 300); + ctx.globalAlpha = alpha; + ctx.fillStyle = p.color; + ctx.font = 'bold 18px "Courier New", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ft(p.text, p.x, p.y); + ctx.globalAlpha = 1; + }); + } + + /* ── HUD: carry status bar ──────────────────────────────────── */ + function renderHUD() { + const bx = SETTINGS.MIDDLE_ZONE_START + 8; + const by = SETTINGS.CANVAS_HEIGHT - 28; + const dotR = 7; + const gap = 18; + + ctx.fillStyle = SETTINGS.COLOR_TEXT; + ctx.font = '11px "Courier New", monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ft('CARRY:', bx, by); + + for (let i = 0; i < SETTINGS.MAX_CARRY; i++) { + const dx = bx + 60 + i * gap; + const filled = i < RS.player.goods.length; + + ctx.beginPath(); + ctx.arc(dx, by, dotR, 0, Math.PI * 2); + ctx.fillStyle = filled ? SETTINGS.COLOR_GOOD : 'rgba(40,80,120,0.5)'; + ctx.fill(); + ctx.strokeStyle = filled ? SETTINGS.COLOR_GOOD_OUTLINE : 'rgba(60,120,180,0.4)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + if (RS.player.hasMedication) { + ctx.fillStyle = SETTINGS.COLOR_MED; + ctx.font = '11px "Courier New", monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ft('💊 CARRYING MED', bx + 170, by); + } + } + + /* ── One side render (zones + player + HUD + popups) ────────── */ + function renderOneSide() { + renderLeftZone(); + renderMiddleZone(); + renderRightZone(); + renderPlayer(); + renderPopups(); + renderHUD(); + } + + /* ── Master render ──────────────────────────────────────────── */ + function render() { + const W = SETTINGS.CANVAS_WIDTH; + const totalW = playerCount === 2 ? W * 2 : W; + + ctx.clearRect(0, 0, totalW, SETTINGS.CANVAS_HEIGHT); + ctx.fillStyle = SETTINGS.COLOR_BG; + ctx.fillRect(0, 0, totalW, SETTINGS.CANVAS_HEIGHT); + + if (playerCount === 2) { + // ── P2 on left half (mirrored) ────────────────────────────── + _pIdx = 1; _mirrored = true; + ctx.save(); + ctx.translate(W, 0); + ctx.scale(-1, 1); + renderOneSide(); + ctx.restore(); + _mirrored = false; + + // ── P1 on right half ───────────────────────────────────────── + _pIdx = 0; + ctx.save(); + ctx.translate(W, 0); + renderOneSide(); + ctx.restore(); + + renderHeader2P(); + } else { + _pIdx = 0; + renderOneSide(); + renderHeader(); + } + } + + /* ── Game loop ──────────────────────────────────────────────── */ + function loop(timestamp) { + if (!running) return; + const dt = Math.min(timestamp - lastTimestamp, 80); + lastTimestamp = timestamp; + update(dt); + render(); + animFrame = requestAnimationFrame(loop); + } + + /* ── Public init ────────────────────────────────────────────── */ + function init(name, name2) { + playerCount = name2 ? 2 : 1; + gNames = name2 ? [name, name2] : [name]; + gScores = Array(playerCount).fill(0); + gPopups = Array.from({ length: playerCount }, () => []); + gHappyTimers = Array(playerCount).fill(0); + timeLeft = SETTINGS.GAME_DURATION * 1000; + + gPlayers = Array.from({ length: playerCount }, () => new Player()); + gLeftBelts = Array.from({ length: playerCount }, () => new LeftConveyorSystem()); + gRightBelts = Array.from({ length: playerCount }, () => new RightConveyorSystem()); + gLabs = Array.from({ length: playerCount }, () => new Lab()); + + keys = {}; + + const W = SETTINGS.CANVAS_WIDTH; + const gameScreen = document.getElementById('game-screen'); + gameScreen.innerHTML = ''; + + canvas = document.createElement('canvas'); + canvas.width = playerCount === 2 ? W * 2 : W; + canvas.height = SETTINGS.CANVAS_HEIGHT; + canvas.id = 'game-canvas'; + canvas.setAttribute('tabindex', '0'); + // Scale down to fit viewport in 2P mode + if (playerCount === 2) { + canvas.style.maxWidth = '100%'; + canvas.style.height = 'auto'; + } + gameScreen.appendChild(canvas); + ctx = canvas.getContext('2d'); + + const legend = document.createElement('div'); + legend.className = 'controls-legend'; + legend.innerHTML = playerCount === 2 + ? 'P1 (right side): ↑ ↓ ← →' + + 'P2 (left side): W S A D' + : 'Move: ↑↓←→ or W A S D' + + 'Collect at left · Deposit / Pickup / Deliver at right'; + gameScreen.appendChild(legend); + + keyHandlerDown = onKeyDown; + keyHandlerUp = onKeyUp; + document.addEventListener('keydown', keyHandlerDown); + document.addEventListener('keyup', keyHandlerUp); + + // ── Mobile D-pad controls ─────────────────────────────────── + buildDPads(gameScreen); + + running = true; + lastTimestamp = performance.now(); + animFrame = requestAnimationFrame(loop); + canvas.focus(); + } + + /* ── Joystick builder ──────────────────────────────────────── */ + function buildDPads(container) { + if (playerCount === 2) { + // P2 on left (mirrored screen → left/right swapped), P1 on right + container.appendChild(makeJoystick('P2', 'joy-left', 1, true)); + container.appendChild(makeJoystick('P1', 'joy-right', 0, false)); + } else { + container.appendChild(makeJoystick('', 'joy-right', 0, false)); + } + } + + function makeJoystick(label, sideClass, pIdx, swapLR) { + const wrap = document.createElement('div'); + wrap.className = `joystick-wrap ${sideClass}`; + + if (label) { + const lbl = document.createElement('div'); + lbl.className = 'joystick-label'; + lbl.textContent = label; + wrap.appendChild(lbl); + } + + const base = document.createElement('div'); + base.className = 'joystick-base'; + + const knob = document.createElement('div'); + knob.className = 'joystick-knob'; + base.appendChild(knob); + wrap.appendChild(base); + + const MAX_TRAVEL = 34; // px the knob can travel from centre + const DEAD_ZONE = 10; // px inner dead zone — no movement triggered + const REPEAT_MS = 160; // how often to re-fire move while held + + let currentDir = null; + let repeatTimer = null; + + function fireMove(dir) { + if (running) gPlayers[pIdx].move(dir); + } + + function setDir(dir) { + if (dir === currentDir) return; + currentDir = dir; + clearInterval(repeatTimer); + repeatTimer = null; + if (dir) { + fireMove(dir); // immediate + repeatTimer = setInterval(() => fireMove(dir), REPEAT_MS); + } + } + + function onTouch(touch) { + const rect = base.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = touch.clientX - cx; + const dy = touch.clientY - cy; + const dist = Math.hypot(dx, dy); + + // Move knob — clamp to MAX_TRAVEL radius + const clamped = Math.min(dist, MAX_TRAVEL); + const angle = Math.atan2(dy, dx); + const kx = Math.cos(angle) * clamped; + const ky = Math.sin(angle) * clamped; + knob.style.transform = `translate(calc(-50% + ${kx}px), calc(-50% + ${ky}px))`; + + if (dist < DEAD_ZONE) { setDir(null); return; } + + // Determine direction from dominant axis + let dir; + if (Math.abs(dx) >= Math.abs(dy)) { + dir = dx > 0 ? (swapLR ? 'left' : 'right') : (swapLR ? 'right' : 'left'); + } else { + dir = dy > 0 ? 'down' : 'up'; + } + setDir(dir); + } + + function onEnd() { + knob.style.transform = 'translate(-50%, -50%)'; + base.classList.remove('active'); + setDir(null); + } + + base.addEventListener('touchstart', e => { + e.preventDefault(); + base.classList.add('active'); + onTouch(e.touches[0]); + }, { passive: false }); + + base.addEventListener('touchmove', e => { + e.preventDefault(); + onTouch(e.touches[0]); + }, { passive: false }); + + base.addEventListener('touchend', onEnd); + base.addEventListener('touchcancel', onEnd); + + return wrap; + } + + return { init }; +})(); diff --git a/kidney_lab/js/highscore.js b/kidney_lab/js/highscore.js new file mode 100644 index 0000000..2659722 --- /dev/null +++ b/kidney_lab/js/highscore.js @@ -0,0 +1,144 @@ +/** + * highscore.js + * High-score screen: displays final score(s), persists top-10 to localStorage, + * shows leaderboard, offers back-to-start button. + * + * show(names, scores, playerCount) + * names — string (1P) or array of strings (2P) + * scores — number (1P) or array of numbers (2P) + * playerCount — 1 or 2 + */ + +const HighScoreScreen = (() => { + + const STORAGE_KEY = 'kidneylab_scores'; + + /* ── Persistence ─────────────────────────────────────────────── */ + + function loadScores() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; + } catch { + return []; + } + } + + function saveScore(name, score) { + const scores = loadScores(); + scores.push({ name: name.toUpperCase().slice(0, 12), score, date: new Date().toLocaleDateString() }); + scores.sort((a, b) => b.score - a.score); + const top10 = scores.slice(0, 10); + localStorage.setItem(STORAGE_KEY, JSON.stringify(top10)); + return top10; + } + + /* ── Helpers ─────────────────────────────────────────────────── */ + + function rankLabel(rank) { + if (rank === 1) return '🏆 NEW HIGH SCORE!'; + if (rank === 2) return '🥈 2nd place!'; + if (rank === 3) return '🥉 3rd place!'; + return `Rank #${rank}`; + } + + function resultCardHTML(playerName, finalScore, scores, myRank) { + const isTop = myRank === 1; + return ` +
+
${rankLabel(myRank)}
+
${playerName.toUpperCase()}
+
${finalScore >= 0 ? finalScore : 0}
+
POINTS
+
`; + } + + function tableHTML(scores, highlightNames, highlightScores) { + const rowsHTML = scores.map((s, i) => { + // highlight any entry that matches one of the just-played players + const isMe = highlightNames.some((n, ni) => + s.name === n.toUpperCase().slice(0, 12) && s.score === highlightScores[ni] + ); + return ` + ${i + 1} + ${s.name} + ${s.score} + ${s.date} + `; + }).join(''); + + return ` +
+

HALL OF FAME

+ + + ${rowsHTML} +
#NAMESCOREDATE
+
`; + } + + /* ── Public show ─────────────────────────────────────────────── */ + + function show(names, scores, playerCount) { + // Normalise to arrays + const nameArr = Array.isArray(names) ? names : [names]; + const scoreArr = Array.isArray(scores) ? scores : [scores]; + const count = playerCount || nameArr.length; + + // Switch screen + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + document.getElementById('highscore-screen').classList.add('active'); + + // Save all players' scores; last save wins for the leaderboard table + let leaderboard; + nameArr.forEach((n, i) => { + leaderboard = saveScore(n, scoreArr[i]); + }); + + // Build rank info for each player + const rankInfo = nameArr.map((n, i) => { + const rank = leaderboard.findIndex( + s => s.name === n.toUpperCase().slice(0, 12) && s.score === scoreArr[i] + ) + 1; + return { name: n, score: scoreArr[i], rank }; + }); + + render(rankInfo, leaderboard, nameArr, scoreArr); + } + + function render(rankInfo, leaderboard, nameArr, scoreArr) { + const el = document.getElementById('highscore-screen'); + + // One result card per player + const cardsHTML = rankInfo.map(r => + resultCardHTML(r.name, r.score, leaderboard, r.rank) + ).join(''); + + // Cards side-by-side for 2P, centred for 1P + const cardsWrap = rankInfo.length > 1 + ? `
${cardsHTML}
` + : cardsHTML; + + el.innerHTML = ` +
+ + + ${cardsWrap} + + ${tableHTML(leaderboard, nameArr, scoreArr)} + +
+ +
+
+ `; + + el.querySelector('#back-btn').addEventListener('click', () => { + IntroScreen.show(); + }); + } + + return { show }; +})(); diff --git a/kidney_lab/js/intro.js b/kidney_lab/js/intro.js new file mode 100644 index 0000000..bcdb0c5 --- /dev/null +++ b/kidney_lab/js/intro.js @@ -0,0 +1,140 @@ +/** + * intro.js + * Introduction screen — two-step flow: + * Step 1: select 1 or 2 players + * Step 2: enter name(s), then start + * Owns the #intro-screen element. + */ + +const IntroScreen = (() => { + + function show() { + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + document.getElementById('intro-screen').classList.add('active'); + renderStep1(); + } + + /* ── Shared header (logo + rules) ─────────────────────────── */ + function headerHTML() { + return ` + + +
+

Help the Doctor collect kidneys from the supply belts + and deliver them to the laboratory for analysis.

+

Bring special medication to the patient to earn bonus points!

+
+ +
+ + + + + +
Kidney delivered to lab+1 pt
Special med to patient+5 pts
Kidney dropped−1 pt
Special kidney dropped−3 pts
+

Carry up to 5 kidneys · 1 in 7 chance is special · 3 minutes on the clock

+
+ +
+

Move with ↑↓←→ or W A S D

+

Stand at column 0 (left) to collect · at column 3 (right) to deposit / pickup / deliver

+
+ `; + } + + /* ── Step 1: player count selection ───────────────────────── */ + function renderStep1() { + const el = document.getElementById('intro-screen'); + el.innerHTML = ` +
+ ${headerHTML()} +
+

SELECT NUMBER OF PLAYERS

+
+ + +
+
+
+ `; + + el.querySelector('#btn-1p').addEventListener('click', () => renderStep2(1)); + el.querySelector('#btn-2p').addEventListener('click', () => renderStep2(2)); + } + + /* ── Step 2: name entry ────────────────────────────────────── */ + function renderStep2(playerCount) { + const el = document.getElementById('intro-screen'); + + const nameFields = playerCount === 2 + ? ` +
+ + +
+
+ + +
` + : ` +
+ + +
`; + + el.innerHTML = ` +
+ ${headerHTML()} +
+
${playerCount === 2 ? '2 PLAYER MODE' : '1 PLAYER MODE'}
+ ${nameFields} +
+ + +
+
+
+ `; + + const p1Input = el.querySelector('#name-p1'); + p1Input.focus(); + + el.querySelector('#back-btn').addEventListener('click', renderStep1); + + el.querySelector('#start-btn').addEventListener('click', () => startGame(playerCount)); + + // Enter on last field starts game + const lastInput = el.querySelector(playerCount === 2 ? '#name-p2' : '#name-p1'); + lastInput.addEventListener('keydown', e => { + if (e.key === 'Enter') startGame(playerCount); + }); + } + + /* ── Launch game ───────────────────────────────────────────── */ + function startGame(playerCount) { + const el = document.getElementById('intro-screen'); + const name1 = (el.querySelector('#name-p1').value.trim() || 'DOCTOR').toUpperCase(); + const name2 = playerCount === 2 + ? ((el.querySelector('#name-p2').value.trim() || 'NURSE').toUpperCase()) + : null; + + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + document.getElementById('game-screen').classList.add('active'); + Game.init(name1, name2); + } + + return { show }; +})(); diff --git a/kidney_lab/js/lab.js b/kidney_lab/js/lab.js new file mode 100644 index 0000000..e134448 --- /dev/null +++ b/kidney_lab/js/lab.js @@ -0,0 +1,84 @@ +/** + * lab.js + * Laboratory logic: accept goods, analyse for special goods, + * trigger medication production. No rendering, no DOM. + */ + +const LabState = Object.freeze({ + IDLE: 'idle', + ANALYZING: 'analyzing', +}); + +class Lab { + constructor() { + this.reset(); + } + + reset() { + this.state = LabState.IDLE; + this.analyzeTimer = 0; + this.totalProcessed = 0; + this.flashTimer = 0; // brief visual flash on deposit + this._onMedReady = null; + } + + /** + * Deposit an array of goods into the lab. + * @param {Good[]} goods – goods from player inventory + * @param {function} onMedicationReady – called (no args) when special analysis completes + * @returns {number} count of goods deposited + */ + depositGoods(goods, onMedicationReady) { + if (!goods.length) return 0; + + let hasSpecial = false; + goods.forEach(good => { + this.totalProcessed++; + if (good.isSpecial) hasSpecial = true; + }); + + // Flash feedback + this.flashTimer = 500; + + // Start analysis only if we got a special good and aren't already analysing + if (hasSpecial && this.state === LabState.IDLE) { + this.state = LabState.ANALYZING; + this.analyzeTimer = SETTINGS.LAB_ANALYZE_TIME; + this._onMedReady = onMedicationReady; + } + + return goods.length; + } + + /** True briefly after goods are deposited (visual feedback). */ + isFlashing() { + return this.flashTimer > 0; + } + + /** True while analysing a special good. */ + isAnalyzing() { + return this.state === LabState.ANALYZING; + } + + /** Progress 0–1 of the current analysis (0 if idle). */ + analyzeProgress() { + if (this.state !== LabState.ANALYZING) return 0; + return 1 - this.analyzeTimer / SETTINGS.LAB_ANALYZE_TIME; + } + + update(dt) { + if (this.flashTimer > 0) this.flashTimer -= dt; + + if (this.state === LabState.ANALYZING) { + this.analyzeTimer -= dt; + if (this.analyzeTimer <= 0) { + this.analyzeTimer = 0; + this.state = LabState.IDLE; + if (this._onMedReady) { + this._onMedReady(); + this._onMedReady = null; + } + } + } + } +} diff --git a/kidney_lab/js/player.js b/kidney_lab/js/player.js new file mode 100644 index 0000000..0acb0fe --- /dev/null +++ b/kidney_lab/js/player.js @@ -0,0 +1,140 @@ +/** + * player.js + * Player state, grid movement, and inventory management. + * No rendering, no DOM — pure logic. + */ + +class Player { + constructor() { + this.reset(); + } + + reset() { + // Grid position: col 0-3 (left → right), row 0-2 (top → bottom) + this.col = 0; + this.row = 1; + + // Carried goods (max SETTINGS.MAX_CARRY) + this.goods = []; + + // Whether the player is holding the medication vial + this.hasMedication = false; + + // Movement cooldown (ms) + this.moveCooldown = 0; + + // Walk-cycle: phase advances (radians) while moveCooldown > 0 + this.walkPhase = 0; + this.animFrame = 0; + this.animTimer = 0; + + // Flash timer when interacting + this.interactFlash = 0; + } + + /* ── Helpers ──────────────────────────────────────────────── */ + + /** Pixel x centre of current column inside the middle zone. */ + pixelX() { + // Evenly distribute columns across the middle zone with a 30px margin + // from each edge, so col 0 sits just right of the belt end and + // col (PLAYER_COLS-1) sits just left of the right-zone border. + const margin = 30; + const avail = SETTINGS.MIDDLE_ZONE_END - SETTINGS.MIDDLE_ZONE_START - margin * 2; + const step = avail / (SETTINGS.PLAYER_COLS - 1); + return SETTINGS.MIDDLE_ZONE_START + margin + this.col * step; + } + + /** Pixel y centre of current row. */ + pixelY() { + return SETTINGS.BELT_ROWS[this.row]; + } + + canMove() { + return this.moveCooldown <= 0; + } + + canCarryMore() { + return this.goods.length < SETTINGS.MAX_CARRY; + } + + /* ── Movement ─────────────────────────────────────────────── */ + + move(direction) { + if (!this.canMove()) return false; + + let nc = this.col; + let nr = this.row; + + switch (direction) { + case 'up': nr = Math.max(0, nr - 1); break; + case 'down': nr = Math.min(SETTINGS.PLAYER_ROWS - 1, nr + 1); break; + case 'left': nc = Math.max(0, nc - 1); break; + case 'right': nc = Math.min(SETTINGS.PLAYER_COLS - 1, nc + 1); break; + default: return false; + } + + if (nc === this.col && nr === this.row) return false; + + this.col = nc; + this.row = nr; + this.moveCooldown = SETTINGS.PLAYER_MOVE_COOLDOWN; + return true; + } + + /* ── Inventory ────────────────────────────────────────────── */ + + /** + * Attempt to add a good to the inventory. + * Returns true on success, false if full. + */ + addGood(good) { + if (!this.canCarryMore()) return false; + this.goods.push(good); + this.interactFlash = 300; + return true; + } + + /** + * Remove and return all carried goods (for lab deposit). + */ + depositGoods() { + const deposited = [...this.goods]; + this.goods = []; + this.interactFlash = 400; + return deposited; + } + + pickupMedication() { + this.hasMedication = true; + this.interactFlash = 400; + } + + /** + * Deliver medication to patient. + * Returns true if medication was delivered. + */ + deliverMedication() { + if (!this.hasMedication) return false; + this.hasMedication = false; + this.interactFlash = 600; + return true; + } + + /* ── Update ───────────────────────────────────────────────── */ + + update(dt) { + if (this.moveCooldown > 0) { + this.moveCooldown -= dt; + // Advance walk cycle: ~π radians per 180 ms move cooldown → one leg swing per step + this.walkPhase += dt * 0.0175; + } + if (this.interactFlash > 0) this.interactFlash -= dt; + + this.animTimer += dt; + if (this.animTimer >= 200) { + this.animTimer -= 200; + this.animFrame = (this.animFrame + 1) % 4; + } + } +} diff --git a/kidney_lab/js/settings.js b/kidney_lab/js/settings.js new file mode 100644 index 0000000..baeaa32 --- /dev/null +++ b/kidney_lab/js/settings.js @@ -0,0 +1,101 @@ +/** + * settings.js + * Static game constants — all tunable values live here. + * Do not import game state; this file has zero side-effects. + */ +const SETTINGS = Object.freeze({ + + /* ── Canvas ───────────────────────────────────────────────── */ + CANVAS_WIDTH: 900, + CANVAS_HEIGHT: 520, + HEADER_HEIGHT: 60, + + /* ── Timing ───────────────────────────────────────────────── */ + GAME_DURATION: 180, // seconds + PLAYER_MOVE_COOLDOWN: 180, // ms between tile moves + LAB_ANALYZE_TIME: 3000, // ms to analyze a special good + GOOD_WAIT_TIME: 2500, // ms a good waits at belt end before dropping + MED_WAIT_TIME: 3500, // ms medication waits at pickup point + + /* ── Scoring ──────────────────────────────────────────────── */ + POINTS_GOOD_LAB: 1, + POINTS_SPECIAL_PATIENT: 5, + POINTS_DROP_GOOD: -1, + POINTS_DROP_SPECIAL: -3, + + /* ── Carry limit ──────────────────────────────────────────── */ + MAX_CARRY: 5, + + /* ── Special good probability (1 in 7) ───────────────────── */ + SPECIAL_GOOD_CHANCE: 1 / 7, + + /* ── Layout — zone x boundaries ──────────────────────────── */ + LEFT_ZONE_START: 0, + LEFT_ZONE_END: 350, + MIDDLE_ZONE_START: 350, + MIDDLE_ZONE_END: 600, + RIGHT_ZONE_START: 600, + RIGHT_ZONE_END: 900, + + /* ── Vertical rows (y centres, shared across all zones) ───── */ + // Row 0 = top, Row 1 = middle, Row 2 = bottom + BELT_ROWS: [155, 300, 445], + + /* ── Player grid (4 cols × 3 rows inside middle zone) ──────── */ + PLAYER_COLS: 3, + PLAYER_ROWS: 3, + PLAYER_COL_WIDTH: 100, // px between column centres + PLAYER_SIZE: 36, // bounding box size for drawing + BELT_HEIGHT: 13, // visual belt strip height (px) + + /* ── Belt speeds ──────────────────────────────────────────── */ + LEFT_BELT_SPEED: 45, // px / sec (goods move → right) + RIGHT_BELT_SPEED: 33, // px / sec (medication moves ← left) + + /* ── Left belt spawn intervals (ms) ──────────────────────── */ + BELT_MIN_SPAWN: 3200, + BELT_MAX_SPAWN: 6500, + + /* ── Staggered initial spawn offsets per belt (ms) ─────────── */ + BELT_INIT_OFFSETS: [600, 2400, 4200], + + /* ── Item sizes ───────────────────────────────────────────── */ + GOOD_SIZE: 26, + + /* ── Patient happy flash duration ────────────────────────── */ + PATIENT_HAPPY_DURATION: 1800, // ms + + /* ── Colours ──────────────────────────────────────────────── */ + COLOR_BG: '#0a1628', + COLOR_HEADER: '#0d1e38', + COLOR_HEADER_LINE: '#1a4070', + COLOR_BELT: '#152840', + COLOR_BELT_STRIPE: '#1e3d5c', + COLOR_BELT_EDGE: '#2a5a80', + COLOR_BELT_R: '#14283c', + COLOR_BELT_STRIPE_R:'#1c3650', + COLOR_GOOD: '#f0a030', + COLOR_GOOD_OUTLINE: '#c07010', + COLOR_SPECIAL: '#ff44aa', + COLOR_SPECIAL_OUT: '#cc1177', + COLOR_MED: '#44ffaa', + COLOR_MED_OUT: '#11cc77', + COLOR_PLAYER: '#4499ff', + COLOR_PLAYER_DARK: '#2266cc', + COLOR_PLAYER_CARRY: '#88ccff', + COLOR_LAB: '#1a4830', + COLOR_LAB_LIGHT: '#22aa55', + COLOR_LAB_OUTLINE: '#33dd77', + COLOR_PATIENT: '#ff8844', + COLOR_PATIENT_DARK: '#cc5511', + COLOR_PATIENT_HAPPY:'#ffdd44', + COLOR_TEXT: '#88ccff', + COLOR_SCORE_VAL: '#ffdd44', + COLOR_TIMER_OK: '#44dd88', + COLOR_TIMER_WARN: '#ffaa22', + COLOR_TIMER_DANGER: '#ff4444', + COLOR_ZONE_DIV: '#1a3a5c', + COLOR_ZONE_DIV2: '#0f2a40', + COLOR_SHADOW: 'rgba(0,0,0,0.45)', + +}); diff --git a/kidney_lab/readme.txt b/kidney_lab/readme.txt new file mode 100644 index 0000000..7e1919f --- /dev/null +++ b/kidney_lab/readme.txt @@ -0,0 +1,24 @@ +Introduction: + +We want to create a html/javascript game that can be played in the browser. The game is a customized clone from the Game and Watch MW-56 Mario game. +The game is build up from the folowing. It start with an introduction screen with some text, a name input field and a start button. +On Click on the start button the game should start. +When finished a high score page is shown with a button to go back to the start screen. + +The GAME: + +On top of the screen there is the amount of points. The name of the player. The amount of time the user has left. +The game screen is build up from three different area's from left to right. + +The left part of the game contains three Automated conveyor belts under each other comming from the left of the screen and transport goods to the right. +The Automated conveyor belt should contain about one third of the screen. The different belt's randomly transport goods. We need to make sure that no two goods start exactly at the same time. + +The second thrird of the screen contains a playable toon that needs to collect the goods that come from the Automated conveyor belts. For this we can go up and down to the location from the three Automated conveyor belts. And he can go to the right three steps. + + +The last 1/3 of the screen contains at the bottom a laboratory space. Above the laboratory a Automated conveyor belt that runs from right to left. At the top a patient who is in pain. + + +Now to play the game the user needs to control the playbale toon. He needs to collect the goods and bring them to the laboratory for analyzing the goods. He can carry a maximum of 5 goods. If he tries to carry more they drop on the floor. At random one of the goods is a special kind of goods. This should happen randonly so that one of the seven goods is a special kind of good. If the special kind of good is added to the laboratory than after a short analyzing a special medication is comming from the Automated conveyor belt on thr right that needs to be taken from the playable toon and bring to the patient. +For every good the player gets into the laboratory the user get 1 point. For giving the special good to the patient he gets 5 points. For every good that drops on the floor the user gets -1 point. If the good dropped was a special good he gets -3 points. The user has 3 minutes playing time and needs to collect as many points as he can. + diff --git a/sde/game.js b/sde/game.js new file mode 100644 index 0000000..06b5c6b --- /dev/null +++ b/sde/game.js @@ -0,0 +1,970 @@ +(function(){ +"use strict"; + +// ═══════════════════════════════════════════ +// Stein der Erinnerung – Full Implementation +// ═══════════════════════════════════════════ + +var CW=800,CH=600; +var PATH_W=36; +var WALK_SPEED=2.4, PUSH_SPEED=1.5, SPRINT_SPEED=4.5; +var STONE_R=14; + +var LEVELS=[ + {name:"Tal der Steine",stones:5,keimGrow:0.003,keimShrink:0.035,medsMax:100,title:"Zystinstein-Lehrling", + skyA:"#3a5068",skyB:"#4a6741",ground:"#3a5230",hintAlpha:0.6,aloneTime:25}, + {name:"Schlucht der Pr\u00FCfung",stones:7,keimGrow:0.005,keimShrink:0.028,medsMax:90,title:"Zystinstein-Kenner", + skyA:"#40455a",skyB:"#5a5040",ground:"#3d4a2e",hintAlpha:0.35,aloneTime:20}, + {name:"Gipfel der Erkenntnis",stones:10,keimGrow:0.008,keimShrink:0.022,medsMax:80,title:"Zystinstein-Guru", + skyA:"#3a3050",skyB:"#604858",ground:"#35402c",hintAlpha:0.18,aloneTime:16} +]; + +var MEDS=[ + {id:"water",name:"Wasser",color:"#4fc3f7",pow:1,icon:"\uD83D\uDCA7"}, + {id:"bicarb",name:"Bikarbonat",color:"#81c784",pow:2.2,icon:"\uD83D\uDC8A"}, + {id:"thiola",name:"Thiola\u00AE",color:"#ffb74d",pow:4,icon:"\uD83D\uDC89"} +]; + +var STONE_COLS=["#8d8d8d","#9e9e9e","#7a7a6e","#a09080","#887766","#7b8b7a","#8e7b6b","#998877","#8a8a7a","#6e7b6e"]; +var CYSTINE_COL="#9a8a6a"; + +// ─── Util ────────────── +function dist(a,b){return Math.sqrt((a.x-b.x)**2+(a.y-b.y)**2)} +function clamp(v,a,b){return Math.max(a,Math.min(b,v))} +function lerp(a,b,t){return a+(b-a)*t} +function rng(a,b){return Math.random()*(b-a)+a} +function lerpCol(a,b,t){ + var pa=[parseInt(a.slice(1,3),16),parseInt(a.slice(3,5),16),parseInt(a.slice(5,7),16)]; + var pb=[parseInt(b.slice(1,3),16),parseInt(b.slice(3,5),16),parseInt(b.slice(5,7),16)]; + return "rgb("+Math.round(lerp(pa[0],pb[0],t))+","+Math.round(lerp(pa[1],pb[1],t))+","+Math.round(lerp(pa[2],pb[2],t))+")"; +} + +// ─── Audio ───────────── +var actx=null; +function ea(){if(!actx)actx=new(window.AudioContext||window.webkitAudioContext)()} +function tn(f,d,v){try{ea();var o=actx.createOscillator(),g=actx.createGain();o.type="sine";o.frequency.value=f;g.gain.value=v||0.06;g.gain.exponentialRampToValueAtTime(0.001,actx.currentTime+d);o.connect(g);g.connect(actx.destination);o.start();o.stop(actx.currentTime+d)}catch(e){}} +function sfxPick(){tn(520,0.12);tn(680,0.1)} +function sfxNeg(){tn(280,0.25)} +function sfxPos(){tn(440,0.15,0.08);setTimeout(function(){tn(660,0.15,0.08)},80);setTimeout(function(){tn(880,0.2,0.08)},160)} +function sfxRefill(){tn(600,0.1);tn(750,0.1)} +function sfxSpray(){tn(900+Math.random()*200,0.06,0.04)} +function sfxWin(){tn(523,0.15,0.1);setTimeout(function(){tn(659,0.15,0.1)},120);setTimeout(function(){tn(784,0.2,0.1)},240);setTimeout(function(){tn(1047,0.3,0.1)},360)} +function sfxLose(){tn(300,0.3,0.08);setTimeout(function(){tn(200,0.4,0.08)},200)} + +// ─── Path Network (orthogonal) ───────── +function genNet(li){ + // Grid-based but irregular: not a checkerboard + var cfg=LEVELS[li]; + var cols=6+li*2, rows=8+li*3; + var gapX=100+rng(-10,10), gapY=80+rng(-10,10); + var offX=80, offY=80; + var mapW=offX*2+cols*gapX, mapH=offY*2+rows*gapY; + + // Create grid nodes with some randomly removed + var grid=[]; // grid[r][c] = node or null + var nodes=[]; + var nid=0; + + for(var r=0;r<=rows;r++){ + grid[r]=[]; + for(var c=0;c<=cols;c++){ + // Always keep edges and some key positions + var keep = r===0||r===rows||c===0||c===cols|| + (r===rows&&c===Math.floor(cols/2))|| // start + (r===0&&c===Math.floor(cols/2))|| // peak + (r===rows-1&&c<=1)|| // lab area + (r===Math.floor(rows*0.4)&&c>=cols-1); // apo area + if(!keep && Math.random()<0.35){ + grid[r][c]=null; continue; + } + // Offset positions slightly for organic feel + var jx=(r>0&&r0&&c0&&r0&&c40&&nx40&&ny=2)show("victory");else{curLvl=l+1;show("game");}}; + show("transition"); +} +function showGO(reason){ + sfxLose(); + $("go-r").textContent=reason; + $("go-retry").onclick=function(){show("game")}; + $("go-rst").onclick=function(){curLvl=0;show("game")}; + $("go-quit").onclick=function(){show("title")}; + show("gameover"); +} + +// ─── Start Game ──────── +function startGame(){ + initGame(curLvl); frame=0; showTut=true; + var c=LEVELS[curLvl]; + $("tut-h").textContent="Level "+(curLvl+1)+": "+c.name; + $("tut-sc").textContent=c.stones; + $("tut-ov").style.display="flex"; + $("tut-ov").onclick=function(){$("tut-ov").style.display="none";showTut=false;ea()}; + updateHUD(); gameLoop(); +} + +// ─── Input ───────────── +document.addEventListener("keydown",function(e){ + keys[e.key.toLowerCase()]=true; + if(e.key==="Shift")keys.shift=true; + if(e.key===" "||e.key==="Enter"){actionQ=true;e.preventDefault()} + if(S){if(e.key==="1")S.selMed=0;if(e.key==="2")S.selMed=1;if(e.key==="3")S.selMed=2;} +}); +document.addEventListener("keyup",function(e){keys[e.key.toLowerCase()]=false;if(e.key==="Shift")keys.shift=false;}); + +var gw=$("screen-game"); +gw.addEventListener("touchstart",function(e){var t=e.touches[0];touch={active:true,sx:t.clientX,sy:t.clientY,dx:0,dy:0}},{passive:false}); +gw.addEventListener("touchmove",function(e){if(!touch.active)return;var t=e.touches[0];touch.dx=t.clientX-touch.sx;touch.dy=t.clientY-touch.sy;e.preventDefault()},{passive:false}); +gw.addEventListener("touchend",function(){if(Math.abs(touch.dx)<15&&Math.abs(touch.dy)<15)actionQ=true;touch.active=false;touch.dx=0;touch.dy=0}); +$("act-btn").addEventListener("click",function(){actionQ=true}); +document.querySelectorAll(".med-b").forEach(function(b,i){b.addEventListener("click",function(){if(S)S.selMed=i})}); +$("btn-start").addEventListener("click",function(){curLvl=0;show("game")}); + +// ─── Game Loop ───────── +function gameLoop(){ + if(curScreen!=="game"||!S)return; + if(showTut){animId=requestAnimationFrame(gameLoop);return} + if(S.won||S.lost)return; + + var dt=1/60; + S.time+=dt;frame++; + + // Input direction + var mx=0,my=0; + if(keys["w"]||keys["arrowup"])my=-1; + if(keys["s"]||keys["arrowdown"])my=1; + if(keys["a"]||keys["arrowleft"])mx=-1; + if(keys["d"]||keys["arrowright"])mx=1; + if(touch.active&&(Math.abs(touch.dx)>15||Math.abs(touch.dy)>15)){ + if(Math.abs(touch.dx)>Math.abs(touch.dy))mx=touch.dx>0?1:-1; + else my=touch.dy>0?1:-1; + } + + // Prefer cardinal: zero out smaller axis for cleaner movement on orthogonal paths + if(mx&&my){if(Math.abs(mx)>=Math.abs(my))my=0;else mx=0;} + + S.player.sprinting=keys.shift&&!S.rollingStone; + var speed=S.rollingStone?PUSH_SPEED:(S.player.sprinting?SPRINT_SPEED:WALK_SPEED); + + if(mx||my){ + var len=Math.sqrt(mx*mx+my*my); + var nx=S.player.x+(mx/len)*speed; + var ny=S.player.y+(my/len)*speed; + // Constrain to paths + var cp=closestOnPaths(nx,ny,S.net.edges,S.net.nodes); + if(cp.d>PATH_W*0.55){nx=cp.x;ny=cp.y;} + nx=clamp(nx,10,S.net.mapW-10); + ny=clamp(ny,10,S.net.mapH-10); + S.player.x=nx;S.player.y=ny; + S.player.dir=Math.atan2(my,mx); + S.player.frame+=S.player.sprinting?0.25:0.15; + } + + // Roll stone alongside player + if(S.rollingStone){ + var rs=S.rollingStone; + // Stone follows player offset in movement direction + var offDist=22; + var sdx=Math.cos(S.player.dir)*offDist; + var sdy=Math.sin(S.player.dir)*offDist; + var tsx=S.player.x+sdx, tsy=S.player.y+sdy; + // Snap stone to path + var scp=closestOnPaths(tsx,tsy,S.net.edges,S.net.nodes); + if(scp.d>PATH_W*0.5){tsx=scp.x;tsy=scp.y;} + // Smooth follow + rs.x=lerp(rs.x,tsx,0.15); + rs.y=lerp(rs.y,tsy,0.15); + // Roll animation + if(mx||my){ + var rollDist=Math.sqrt((rs.x-tsx)**2+(rs.y-tsy)**2); + rs.rollAngle+=(mx||my)*0.08; + } + } + + // Action + if(actionQ){actionQ=false;handleAction()} + + // Check pharmacy proximity + S.atPharmacy=dist(S.player,S.net.apo)<65; + + // Phase 2 logic + if(S.phase===2){ + S.phase2Timer+=dt; + + // Grow keims + for(var ki=0;ki100&&dist(kp,S.net.apo)>70&&dist(kp,S.net.lab)>70){ + S.keims.push({x:kp.x,y:kp.y,size:0.06,id:Date.now()+Math.random(),pulse:0}); + } + } + + // Kidney damage + var totK=0; + for(var ki=0;ki=100){ + S.lost=true; + showGO("Die Kristallkeime haben die Niere kritisch gesch\u00E4digt. Der Stein kann nicht mehr bewegt werden."); + return; + } + if(S.stoneAloneTimer>=S.maxAloneTime){ + S.lost=true; + showGO("Der Weg zum Gipfel ist von Kristallen dauerhaft blockiert. Der Zystinstein sitzt fest!"); + return; + } + + // Win check + if(S.rollingStone&&S.rollingStone.isCystine&&dist(S.rollingStone,S.net.peak)<50){ + S.won=true; + S.score+=Math.max(0,Math.floor(1000-S.phase2Timer*5+(100-S.kidneyDmg)*5)); + sfxWin(); + setTimeout(function(){showTrans(curLvl)},700); + return; + } + + // Passive med drain (slow) + ["water","bicarb","thiola"].forEach(function(m){ + S.meds[m]=Math.max(0,S.meds[m]-dt*(0.3+curLvl*0.15)); + }); + } + + // Spray particles update + for(var pi=sprayParticles.length-1;pi>=0;pi--){ + var p=sprayParticles[pi]; + p.life-=dt; + p.x+=p.vx*dt;p.y+=p.vy*dt; + p.vx*=0.95;p.vy*=0.95; + if(p.life<=0)sprayParticles.splice(pi,1); + } + + if(S.msgTimer>0){S.msgTimer-=dt;if(S.msgTimer<=0)S.msg="";} + + // Camera + var tcx=S.player.x-CW/2,tcy=S.player.y-CH/2; + S.camera.x=lerp(S.camera.x,clamp(tcx,0,S.net.mapW-CW),0.1); + S.camera.y=lerp(S.camera.y,clamp(tcy,0,S.net.mapH-CH),0.1); + + render(); + if(frame%5===0)updateHUD(); + + animId=requestAnimationFrame(gameLoop); +} + +// ─── Action ──────────── +function handleAction(){ + if(!S)return; + var P=S.player; + + // ── Phase 1 ── + if(S.phase===1){ + if(!S.rollingStone){ + // Pick up nearest unscanned stone + var best=null,bd=Infinity; + S.stones.forEach(function(st){ + if(st.scanned||st.rolling)return; + var d=dist(P,st); + if(d<55&&d0){ + var hit=false; + for(var ki=0;ki0.02}); + } + } +} + +function setMsg(m,d,al){S.msg=m;S.msgTimer=d;S.msgAl=!!al;} + +// ─── HUD ─────────────── +function updateHUD(){ + if(!S)return; + $("hud-lv").textContent="Lvl "+(curLvl+1); + $("hud-sc").textContent="\u2B50 "+S.score; + $("hud-ph").textContent=S.phase===1?("\uD83D\uDD0D "+S.stonesScanned+"/"+S.stones.length):"\u26A1 Phase 2"; + + var kw=$("kid-w"),kf=$("kid-f"); + if(S.phase===2){ + kw.style.display="flex"; + kf.style.width=S.kidneyDmg+"%"; + kf.className="bar-f"+(S.kidneyDmg>70?" bar-dn":S.kidneyDmg>40?" bar-wn":" bar-ok"); + }else kw.style.display="none"; + + var te=$("st-tm"); + if(S.phase===2&&S.parkedStone&&!S.rollingStone&&S.stoneAloneTimer>0){ + var rem=Math.max(0,S.maxAloneTime-S.stoneAloneTimer); + te.textContent="\u23F0 "+rem.toFixed(0)+"s"; + te.className="st-tm"+(S.stoneAloneTimer>S.maxAloneTime*0.6?" st-d":" st-w"); + te.style.display="inline"; + }else te.style.display="none"; + + var bot=$("hud-bot"); + if(S.phase===2)bot.classList.add("vis");else bot.classList.remove("vis"); + + MEDS.forEach(function(m,i){ + var b=$("med-"+i),fl=$("mfl-"+i),st=$("mst-"+i); + var pct=(S.meds[m.id]/S.medsMax)*100; + b.className="med-b"+(S.selMed===i?" sel":""); + b.style.borderColor=S.selMed===i?m.color:"#333"; + fl.style.width=pct+"%"; + fl.style.background=pct<20?"#e53935":m.color; + st.textContent=pct<20?"NIEDRIG!":Math.round(pct)+"%"; + st.className="med-st"+(pct<20?" med-lo":" med-ok"); + }); + + var mp=$("msg-pop"); + if(S.msg){mp.style.display="block";mp.textContent=S.msg;mp.className="msg-pop"+(S.msgAl?" msg-al":"");} + else mp.style.display="none"; +} + +// ─── Render ──────────── +function render(){ + var canvas=$("gc"),ctx=canvas.getContext("2d"); + var cam=S.camera,P=S.player,net=S.net,cfg=S.cfg; + + ctx.save();ctx.clearRect(0,0,CW,CH); + + // Sky + var sg=ctx.createLinearGradient(0,0,0,CH); + if(S.phase===1){sg.addColorStop(0,cfg.skyA);sg.addColorStop(1,cfg.skyB);} + else{var u=Math.min(1,S.kidneyDmg/80);sg.addColorStop(0,lerpCol(cfg.skyA,"#5a2020",u));sg.addColorStop(1,lerpCol(cfg.skyB,"#3a1515",u));} + ctx.fillStyle=sg;ctx.fillRect(0,0,CW,CH); + + ctx.translate(-cam.x,-cam.y); + + // Ground + ctx.fillStyle=cfg.ground;ctx.fillRect(0,0,net.mapW,net.mapH); + // Ground specks + ctx.fillStyle="#00000014"; + for(var gi=0;gi<250;gi++)ctx.fillRect((gi*137.5)%net.mapW,(gi*97.3)%net.mapH,2,2); + + // ── Paths ── + ctx.lineCap="round";ctx.lineJoin="round"; + // Shadow + ctx.strokeStyle="#1a150f";ctx.lineWidth=PATH_W+8; + net.edges.forEach(function(e){var a=net.nodes[e[0]],b=net.nodes[e[1]];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}); + // Main path + ctx.strokeStyle="#8a7a5e";ctx.lineWidth=PATH_W; + net.edges.forEach(function(e){var a=net.nodes[e[0]],b=net.nodes[e[1]];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}); + // Center highlight + ctx.strokeStyle="#a0906840";ctx.lineWidth=PATH_W*0.5; + net.edges.forEach(function(e){var a=net.nodes[e[0]],b=net.nodes[e[1]];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}); + + // ── Bushes (behind trees) ── + var bCols=["#2d6b1e","#3a7a2a","#1e5a14"]; + S.veg.bushes.forEach(function(b){ + ctx.fillStyle=bCols[b.shade%3]; + ctx.beginPath();ctx.arc(b.x,b.y,b.sz,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#22551540"; + ctx.beginPath();ctx.arc(b.x-b.sz*0.3,b.y-b.sz*0.3,b.sz*0.5,0,Math.PI*2);ctx.fill(); + }); + + // ── Trees ── + var tCols=["#1e6b1e","#2a7a25","#22651a","#2b7030"]; + S.veg.trees.forEach(function(t){ + ctx.fillStyle="#5a3a1a";ctx.fillRect(t.x-3,t.y,6,t.trunk); + ctx.fillStyle="#00000020";ctx.beginPath();ctx.ellipse(t.x,t.y+t.trunk+2,t.sz*0.6,t.sz*0.25,0,0,Math.PI*2);ctx.fill(); + ctx.fillStyle=tCols[t.shade%4]; + ctx.beginPath();ctx.arc(t.x,t.y-t.sz*0.2,t.sz,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#3a9a3018";ctx.beginPath();ctx.arc(t.x-t.sz*0.25,t.y-t.sz*0.5,t.sz*0.45,0,Math.PI*2);ctx.fill(); + }); + + // ── Keims (yellow crystals with red aura) ── + for(var ki=0;ki0.35){ + ctx.strokeStyle="rgba(255,50,50,"+(k.size*0.6)+")"; + ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(k.x,k.y,r2*2,0,Math.PI*2);ctx.stroke(); + } + } + + // ── Spray particles ── + sprayParticles.forEach(function(p){ + var alpha=p.life*1.5; + ctx.fillStyle=p.color.replace(")",","+alpha+")").replace("rgb","rgba"); + ctx.beginPath();ctx.arc(p.x,p.y,2+p.life*3,0,Math.PI*2);ctx.fill(); + }); + + // ── Buildings ── + drawBldg(ctx,net.lab.x,net.lab.y,"\uD83D\uDD2C","Labor","#304050"); + drawBldg(ctx,net.apo.x,net.apo.y,"\uD83D\uDC8A","Apotheke","#2a4a2a"); + + // Peak + ctx.fillStyle="#9a9a8a"; + ctx.beginPath();ctx.moveTo(net.peak.x-50,net.peak.y+30);ctx.lineTo(net.peak.x-12,net.peak.y-40);ctx.lineTo(net.peak.x+8,net.peak.y-35);ctx.lineTo(net.peak.x+50,net.peak.y+30);ctx.closePath();ctx.fill(); + ctx.fillStyle="#e0e0e0";ctx.beginPath();ctx.moveTo(net.peak.x-18,net.peak.y-22);ctx.lineTo(net.peak.x-12,net.peak.y-40);ctx.lineTo(net.peak.x+8,net.peak.y-35);ctx.lineTo(net.peak.x+14,net.peak.y-20);ctx.closePath();ctx.fill(); + ctx.fillStyle="#fff8";ctx.font="11px sans-serif";ctx.textAlign="center";ctx.fillText("\u26F0\uFE0F Gipfel",net.peak.x,net.peak.y+48); + + // ── Hints (phase 1) ── + if(S.phase===1){ + var hAlpha=S.cfg.hintAlpha; + S.hints.forEach(function(h){ + var sp=Math.sin(frame*0.06+h.phase)*0.5+0.5; + ctx.fillStyle="rgba(255,240,180,"+(sp*hAlpha)+")"; + ctx.beginPath();ctx.arc(h.x,h.y,3+sp*3,0,Math.PI*2);ctx.fill(); + }); + } + + // ── Stones ── + S.stones.forEach(function(st){ + if(st.rolling)return; + if(st.scanned&&!st.isCystine){ + ctx.globalAlpha=0.25;ctx.fillStyle="#555";drawStone(ctx,st.x,st.y,st.shape,st.rollAngle);ctx.globalAlpha=1;return; + } + ctx.fillStyle=st.color; + drawStone(ctx,st.x,st.y,st.shape,st.rollAngle); + if(!st.scanned&&S.phase===1){ + ctx.fillStyle="#fff7";ctx.font="11px sans-serif";ctx.textAlign="center";ctx.fillText("?",st.x,st.y-18); + } + }); + + // Rolling stone + if(S.rollingStone){ + var rs=S.rollingStone; + ctx.fillStyle=rs.color; + drawStone(ctx,rs.x,rs.y,rs.shape,rs.rollAngle); + // Rolling dust + if(frame%3===0){ + ctx.fillStyle="#a0906840"; + ctx.beginPath();ctx.arc(rs.x+rng(-8,8),rs.y+rng(8,14),rng(2,4),0,Math.PI*2);ctx.fill(); + } + } + + // Parked stone glow + if(S.parkedStone){ + var gl=Math.sin(frame*0.08)*0.3+0.5; + ctx.strokeStyle="rgba(255,200,50,"+gl+")";ctx.lineWidth=2; + ctx.beginPath();ctx.arc(S.parkedStone.x,S.parkedStone.y,STONE_R+8,0,Math.PI*2);ctx.stroke(); + ctx.fillStyle=S.parkedStone.color; + drawStone(ctx,S.parkedStone.x,S.parkedStone.y,S.parkedStone.shape,S.parkedStone.rollAngle); + } + + // ── Player (Sisyphus) ── + var bob=Math.sin(P.frame*2)*1.5; + var sprinting=P.sprinting; + // Shadow + ctx.fillStyle="#00000028";ctx.beginPath();ctx.ellipse(P.x,P.y+20+bob,10,4,0,0,Math.PI*2);ctx.fill(); + // Head + ctx.fillStyle="#d4a060";ctx.beginPath();ctx.arc(P.x,P.y-14+bob,9,0,Math.PI*2);ctx.fill(); + // Hair + ctx.fillStyle="#5a3a1a";ctx.beginPath();ctx.arc(P.x,P.y-18+bob,6,Math.PI,Math.PI*2);ctx.fill(); + // Body + ctx.fillStyle=sprinting?"#7a5535":"#8b6040"; + ctx.fillRect(P.x-6,P.y-5+bob,12,15); + // Arms (extended when pushing) + if(S.rollingStone){ + var armDx=Math.cos(P.dir)*10,armDy=Math.sin(P.dir)*10; + ctx.strokeStyle="#d4a060";ctx.lineWidth=3; + ctx.beginPath();ctx.moveTo(P.x,P.y+2+bob);ctx.lineTo(P.x+armDx,P.y+2+bob+armDy);ctx.stroke(); + } + // Belt + ctx.fillStyle="#4a3020";ctx.fillRect(P.x-7,P.y+3+bob,14,3); + // Legs + ctx.fillStyle="#6a4830"; + var lOff=Math.sin(P.frame*3)*(sprinting?3:1.5); + ctx.fillRect(P.x-5,P.y+10+bob+lOff,4,8); + ctx.fillRect(P.x+1,P.y+10+bob-lOff,4,8); + + // Sprint trail + if(sprinting&&frame%2===0){ + ctx.fillStyle="#a0906830"; + ctx.beginPath();ctx.arc(P.x-Math.cos(P.dir)*12,P.y-Math.sin(P.dir)*12+20,rng(2,4),0,Math.PI*2);ctx.fill(); + } + + // ── Prompts ── + ctx.font="bold 11px sans-serif";ctx.textAlign="center"; + if(S.phase===1){ + S.stones.forEach(function(st){ + if(!st.scanned&&!st.rolling&&dist(P,st)<55){ + ctx.fillStyle="#ffffffcc";ctx.fillText("[Leertaste] Aufheben",st.x,st.y-22); + } + }); + if(S.rollingStone&&dist(P,net.lab)<65){ + ctx.fillStyle="#00ff88cc";ctx.font="bold 12px sans-serif"; + ctx.fillText("[Leertaste] Scannen!",net.lab.x,net.lab.y-48); + } + } + if(S.phase===2){ + if(S.parkedStone&&!S.rollingStone&&dist(P,S.parkedStone)<55){ + ctx.fillStyle="#ffcc00cc";ctx.fillText("[Leertaste] Aufheben",S.parkedStone.x,S.parkedStone.y-STONE_R-14); + } + if(S.rollingStone&&S.rollingStone.isCystine){ + ctx.fillStyle="#88aaffcc";ctx.font="10px sans-serif";ctx.fillText("[Leertaste] Parken",S.rollingStone.x,S.rollingStone.y-STONE_R-14); + } + if(dist(P,net.apo)<65){ + ctx.fillStyle="#00ff88cc";ctx.fillText("[Leertaste] Auff\u00FCllen",net.apo.x,net.apo.y-48); + } + for(var ki=0;ki0){ + ctx.fillStyle="#ffcc44cc";ctx.font="10px sans-serif"; + ctx.fillText("[Leertaste] Spr\u00FChen",k.x,k.y-k.size*30-12); + break; // only show one prompt + } + } + } + + // ── Minimap ── + drawMinimap(ctx,cam); + + ctx.restore(); +} + +// ─── Draw helpers ────── +function drawStone(ctx,x,y,shape,angle){ + ctx.save();ctx.translate(x,y);ctx.rotate(angle||0); + ctx.beginPath(); + if(shape===0)ctx.ellipse(0,0,STONE_R,STONE_R*0.72,0,0,Math.PI*2); + else if(shape===1){ctx.moveTo(-STONE_R,STONE_R*0.6);ctx.lineTo(-STONE_R*0.5,-STONE_R*0.8);ctx.lineTo(STONE_R*0.8,-STONE_R*0.5);ctx.lineTo(STONE_R,STONE_R*0.6);ctx.closePath();} + else ctx.arc(0,0,STONE_R,0,Math.PI*2); + ctx.fill();ctx.strokeStyle="#00000030";ctx.lineWidth=1;ctx.stroke(); + // Stone texture + ctx.fillStyle="#00000015";ctx.beginPath();ctx.arc(STONE_R*0.2,-STONE_R*0.2,STONE_R*0.3,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#ffffff10";ctx.beginPath();ctx.arc(-STONE_R*0.3,-STONE_R*0.3,STONE_R*0.25,0,Math.PI*2);ctx.fill(); + ctx.restore(); +} + +function drawCrystal(ctx,x,y,r){ + ctx.beginPath(); + var spikes=7; + for(var i=0;i + + + + + Stein der Erinnerung – Die Sisyphus-Saga der Zystinurie + + + + + +
+
+
🪨
+

Stein der Erinnerung

+

Die Sisyphus-Saga der Zystinurie

+ +
+ Finde den Zystinstein. Rolle ihn zum Gipfel.
+ Halte die Kristallkeime unter Kontrolle.
+ Werde zum Zystinstein-Guru. +
+
+ WASD / Pfeiltasten = Bewegen • Shift = Sprint • Leertaste = Aktion • 1-2-3 = Medikament
+ Mobil: Swipe = Bewegen • Tap = Aktion • ⚡-Button = Aktion +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sde/js/audio.js b/sde/js/audio.js new file mode 100644 index 0000000..0d11646 --- /dev/null +++ b/sde/js/audio.js @@ -0,0 +1,12 @@ +"use strict"; + +var actx=null; +function ea(){if(!actx)actx=new(window.AudioContext||window.webkitAudioContext)()} +function tn(f,d,v){try{ea();var o=actx.createOscillator(),g=actx.createGain();o.type="sine";o.frequency.value=f;g.gain.value=v||0.06;g.gain.exponentialRampToValueAtTime(0.001,actx.currentTime+d);o.connect(g);g.connect(actx.destination);o.start();o.stop(actx.currentTime+d)}catch(e){}} +function sfxPick(){tn(520,0.12);tn(680,0.1)} +function sfxNeg(){tn(280,0.25)} +function sfxPos(){tn(440,0.15,0.08);setTimeout(function(){tn(660,0.15,0.08)},80);setTimeout(function(){tn(880,0.2,0.08)},160)} +function sfxRefill(){tn(600,0.1);tn(750,0.1)} +function sfxSpray(){tn(900+Math.random()*200,0.06,0.04)} +function sfxWin(){tn(523,0.15,0.1);setTimeout(function(){tn(659,0.15,0.1)},120);setTimeout(function(){tn(784,0.2,0.1)},240);setTimeout(function(){tn(1047,0.3,0.1)},360)} +function sfxLose(){tn(300,0.3,0.08);setTimeout(function(){tn(200,0.4,0.08)},200)} diff --git a/sde/js/constants.js b/sde/js/constants.js new file mode 100644 index 0000000..c26e026 --- /dev/null +++ b/sde/js/constants.js @@ -0,0 +1,24 @@ +"use strict"; + +var CW=800,CH=600; +var PATH_W=36; +var WALK_SPEED=2.4, PUSH_SPEED=1.5, SPRINT_SPEED=4.5; +var STONE_R=14; + +var LEVELS=[ + {name:"Tal der Steine",stones:5,keimGrow:0.003,keimShrink:0.035,medsMax:100,title:"Zystinstein-Lehrling", + skyA:"#3a5068",skyB:"#4a6741",ground:"#3a5230",hintAlpha:0.6,aloneTime:25}, + {name:"Schlucht der Pr\u00FCfung",stones:7,keimGrow:0.005,keimShrink:0.028,medsMax:90,title:"Zystinstein-Kenner", + skyA:"#40455a",skyB:"#5a5040",ground:"#3d4a2e",hintAlpha:0.35,aloneTime:20}, + {name:"Gipfel der Erkenntnis",stones:10,keimGrow:0.008,keimShrink:0.022,medsMax:80,title:"Zystinstein-Guru", + skyA:"#3a3050",skyB:"#604858",ground:"#35402c",hintAlpha:0.18,aloneTime:16} +]; + +var MEDS=[ + {id:"water",name:"Wasser",color:"#4fc3f7",pow:1,icon:"\uD83D\uDCA7"}, + {id:"bicarb",name:"Bikarbonat",color:"#81c784",pow:2.2,icon:"\uD83D\uDC8A"}, + {id:"thiola",name:"Thiola\u00AE",color:"#ffb74d",pow:4,icon:"\uD83D\uDC89"} +]; + +var STONE_COLS=["#8d8d8d","#9e9e9e","#7a7a6e","#a09080","#887766","#7b8b7a","#8e7b6b","#998877","#8a8a7a","#6e7b6e"]; +var CYSTINE_COL="#9a8a6a"; diff --git a/sde/js/gameloop.js b/sde/js/gameloop.js new file mode 100644 index 0000000..f2df38a --- /dev/null +++ b/sde/js/gameloop.js @@ -0,0 +1,287 @@ +"use strict"; + +// ─── Game Loop ───────── +function gameLoop(){ + if(curScreen!=="game"||!S)return; + if(showTut){animId=requestAnimationFrame(gameLoop);return} + if(S.won||S.lost)return; + + var dt=1/60; + S.time+=dt;frame++; + + var mx=0,my=0; + if(keys["w"]||keys["arrowup"])my=-1; + if(keys["s"]||keys["arrowdown"])my=1; + if(keys["a"]||keys["arrowleft"])mx=-1; + if(keys["d"]||keys["arrowright"])mx=1; + if(touch.active&&(Math.abs(touch.dx)>15||Math.abs(touch.dy)>15)){ + if(Math.abs(touch.dx)>Math.abs(touch.dy))mx=touch.dx>0?1:-1; + else my=touch.dy>0?1:-1; + } + + if(mx&&my){if(Math.abs(mx)>=Math.abs(my))my=0;else mx=0;} + + S.player.sprinting=keys.shift&&!S.rollingStone; + var speed=S.rollingStone?PUSH_SPEED:(S.player.sprinting?SPRINT_SPEED:WALK_SPEED); + + if(mx||my){ + var len=Math.sqrt(mx*mx+my*my); + var nx=S.player.x+(mx/len)*speed; + var ny=S.player.y+(my/len)*speed; + var cp=closestOnPaths(nx,ny,S.net.edges,S.net.nodes); + if(cp.d>PATH_W*0.55){nx=cp.x;ny=cp.y;} + nx=clamp(nx,10,S.net.mapW-10); + ny=clamp(ny,10,S.net.mapH-10); + S.player.x=nx;S.player.y=ny; + S.player.dir=Math.atan2(my,mx); + S.player.frame+=S.player.sprinting?0.25:0.15; + } + + if(S.rollingStone){ + var rs=S.rollingStone; + var offDist=22; + var sdx=Math.cos(S.player.dir)*offDist; + var sdy=Math.sin(S.player.dir)*offDist; + var tsx=S.player.x+sdx, tsy=S.player.y+sdy; + var scp=closestOnPaths(tsx,tsy,S.net.edges,S.net.nodes); + if(scp.d>PATH_W*0.5){tsx=scp.x;tsy=scp.y;} + rs.x=lerp(rs.x,tsx,0.15); + rs.y=lerp(rs.y,tsy,0.15); + if(mx||my){ + rs.rollAngle+=(mx||my)*0.08; + } + } + + if(actionQ){actionQ=false;handleAction()} + + S.atPharmacy=dist(S.player,S.net.apo)<65; + + if(S.phase===2){ + S.phase2Timer+=dt; + + for(var ki=0;ki100&&dist(kp,S.net.apo)>70&&dist(kp,S.net.lab)>70){ + S.keims.push({x:kp.x,y:kp.y,size:0.06,id:Date.now()+Math.random(),pulse:0}); + } + } + + var totK=0; + for(var ki=0;ki=100){ + S.lost=true; + showGO("Die Kristallkeime haben die Niere kritisch gesch\u00E4digt. Der Stein kann nicht mehr bewegt werden."); + return; + } + if(S.stoneAloneTimer>=S.maxAloneTime){ + S.lost=true; + showGO("Der Weg zum Gipfel ist von Kristallen dauerhaft blockiert. Der Zystinstein sitzt fest!"); + return; + } + + if(S.rollingStone&&S.rollingStone.isCystine&&dist(S.rollingStone,S.net.peak)<50){ + S.won=true; + S.score+=Math.max(0,Math.floor(1000-S.phase2Timer*5+(100-S.kidneyDmg)*5)); + sfxWin(); + setTimeout(function(){showTrans(curLvl)},700); + return; + } + + ["water","bicarb","thiola"].forEach(function(m){ + S.meds[m]=Math.max(0,S.meds[m]-dt*(0.3+curLvl*0.15)); + }); + } + + for(var pi=sprayParticles.length-1;pi>=0;pi--){ + var p=sprayParticles[pi]; + p.life-=dt; + p.x+=p.vx*dt;p.y+=p.vy*dt; + p.vx*=0.95;p.vy*=0.95; + if(p.life<=0)sprayParticles.splice(pi,1); + } + + if(S.msgTimer>0){S.msgTimer-=dt;if(S.msgTimer<=0)S.msg="";} + + var tcx=S.player.x-CW/2,tcy=S.player.y-CH/2; + S.camera.x=lerp(S.camera.x,clamp(tcx,0,S.net.mapW-CW),0.1); + S.camera.y=lerp(S.camera.y,clamp(tcy,0,S.net.mapH-CH),0.1); + + render(); + if(frame%5===0)updateHUD(); + + animId=requestAnimationFrame(gameLoop); +} + +// ─── Action ──────────── +function handleAction(){ + if(!S)return; + var P=S.player; + + if(S.phase===1){ + if(!S.rollingStone){ + var best=null,bd=Infinity; + S.stones.forEach(function(st){ + if(st.scanned||st.rolling)return; + var d=dist(P,st); + if(d<55&&d0){ + var hit=false; + for(var ki=0;ki0.02}); + } + } +} + +function setMsg(m,d,al){S.msg=m;S.msgTimer=d;S.msgAl=!!al;} + +// ─── HUD ─────────────── +function updateHUD(){ + if(!S)return; + $("hud-lv").textContent="Lvl "+(curLvl+1); + $("hud-sc").textContent="\u2B50 "+S.score; + $("hud-ph").textContent=S.phase===1?("\uD83D\uDD0D "+S.stonesScanned+"/"+S.stones.length):"\u26A1 Phase 2"; + + var kw=$("kid-w"),kf=$("kid-f"); + if(S.phase===2){ + kw.style.display="flex"; + kf.style.width=S.kidneyDmg+"%"; + kf.className="bar-f"+(S.kidneyDmg>70?" bar-dn":S.kidneyDmg>40?" bar-wn":" bar-ok"); + }else kw.style.display="none"; + + var te=$("st-tm"); + if(S.phase===2&&S.parkedStone&&!S.rollingStone&&S.stoneAloneTimer>0){ + var rem=Math.max(0,S.maxAloneTime-S.stoneAloneTimer); + te.textContent="\u23F0 "+rem.toFixed(0)+"s"; + te.className="st-tm"+(S.stoneAloneTimer>S.maxAloneTime*0.6?" st-d":" st-w"); + te.style.display="inline"; + }else te.style.display="none"; + + var bot=$("hud-bot"); + if(S.phase===2)bot.classList.add("vis");else bot.classList.remove("vis"); + + MEDS.forEach(function(m,i){ + var b=$("med-"+i),fl=$("mfl-"+i),st=$("mst-"+i); + var pct=(S.meds[m.id]/S.medsMax)*100; + b.className="med-b"+(S.selMed===i?" sel":""); + b.style.borderColor=S.selMed===i?m.color:"#333"; + fl.style.width=pct+"%"; + fl.style.background=pct<20?"#e53935":m.color; + st.textContent=pct<20?"NIEDRIG!":Math.round(pct)+"%"; + st.className="med-st"+(pct<20?" med-lo":" med-ok"); + }); + + var mp=$("msg-pop"); + if(S.msg){mp.style.display="block";mp.textContent=S.msg;mp.className="msg-pop"+(S.msgAl?" msg-al":"");} + else mp.style.display="none"; +} diff --git a/sde/js/input.js b/sde/js/input.js new file mode 100644 index 0000000..f9bb205 --- /dev/null +++ b/sde/js/input.js @@ -0,0 +1,17 @@ +"use strict"; + +document.addEventListener("keydown",function(e){ + keys[e.key.toLowerCase()]=true; + if(e.key==="Shift")keys.shift=true; + if(e.key===" "||e.key==="Enter"){actionQ=true;e.preventDefault()} + if(S){if(e.key==="1")S.selMed=0;if(e.key==="2")S.selMed=1;if(e.key==="3")S.selMed=2;} +}); +document.addEventListener("keyup",function(e){keys[e.key.toLowerCase()]=false;if(e.key==="Shift")keys.shift=false;}); + +var gw=$("screen-game"); +gw.addEventListener("touchstart",function(e){var t=e.touches[0];touch={active:true,sx:t.clientX,sy:t.clientY,dx:0,dy:0}},{passive:false}); +gw.addEventListener("touchmove",function(e){if(!touch.active)return;var t=e.touches[0];touch.dx=t.clientX-touch.sx;touch.dy=t.clientY-touch.sy;e.preventDefault()},{passive:false}); +gw.addEventListener("touchend",function(){if(Math.abs(touch.dx)<15&&Math.abs(touch.dy)<15)actionQ=true;touch.active=false;touch.dx=0;touch.dy=0}); +$("act-btn").addEventListener("click",function(){actionQ=true}); +document.querySelectorAll(".med-b").forEach(function(b,i){b.addEventListener("click",function(){if(S)S.selMed=i})}); +$("btn-start").addEventListener("click",function(){curLvl=0;show("game")}); diff --git a/sde/js/main.js b/sde/js/main.js new file mode 100644 index 0000000..b268c8b --- /dev/null +++ b/sde/js/main.js @@ -0,0 +1,3 @@ +"use strict"; + +show("title"); diff --git a/sde/js/render.js b/sde/js/render.js new file mode 100644 index 0000000..98f003a --- /dev/null +++ b/sde/js/render.js @@ -0,0 +1,250 @@ +"use strict"; + +function render(){ + var canvas=$("gc"),ctx=canvas.getContext("2d"); + var cam=S.camera,P=S.player,net=S.net,cfg=S.cfg; + + ctx.save();ctx.clearRect(0,0,CW,CH); + + // Sky + var sg=ctx.createLinearGradient(0,0,0,CH); + if(S.phase===1){sg.addColorStop(0,cfg.skyA);sg.addColorStop(1,cfg.skyB);} + else{var u=Math.min(1,S.kidneyDmg/80);sg.addColorStop(0,lerpCol(cfg.skyA,"#5a2020",u));sg.addColorStop(1,lerpCol(cfg.skyB,"#3a1515",u));} + ctx.fillStyle=sg;ctx.fillRect(0,0,CW,CH); + + ctx.translate(-cam.x,-cam.y); + + // Ground + ctx.fillStyle=cfg.ground;ctx.fillRect(0,0,net.mapW,net.mapH); + ctx.fillStyle="#00000014"; + for(var gi=0;gi<250;gi++)ctx.fillRect((gi*137.5)%net.mapW,(gi*97.3)%net.mapH,2,2); + + // ── Paths ── + ctx.lineCap="round";ctx.lineJoin="round"; + ctx.strokeStyle="#1a150f";ctx.lineWidth=PATH_W+8; + net.edges.forEach(function(e){var a=net.nodes[e[0]],b=net.nodes[e[1]];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}); + ctx.strokeStyle="#8a7a5e";ctx.lineWidth=PATH_W; + net.edges.forEach(function(e){var a=net.nodes[e[0]],b=net.nodes[e[1]];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}); + ctx.strokeStyle="#a0906840";ctx.lineWidth=PATH_W*0.5; + net.edges.forEach(function(e){var a=net.nodes[e[0]],b=net.nodes[e[1]];ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}); + + // ── Bushes ── + var bCols=["#2d6b1e","#3a7a2a","#1e5a14"]; + S.veg.bushes.forEach(function(b){ + ctx.fillStyle=bCols[b.shade%3]; + ctx.beginPath();ctx.arc(b.x,b.y,b.sz,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#22551540"; + ctx.beginPath();ctx.arc(b.x-b.sz*0.3,b.y-b.sz*0.3,b.sz*0.5,0,Math.PI*2);ctx.fill(); + }); + + // ── Trees ── + var tCols=["#1e6b1e","#2a7a25","#22651a","#2b7030"]; + S.veg.trees.forEach(function(t){ + ctx.fillStyle="#5a3a1a";ctx.fillRect(t.x-3,t.y,6,t.trunk); + ctx.fillStyle="#00000020";ctx.beginPath();ctx.ellipse(t.x,t.y+t.trunk+2,t.sz*0.6,t.sz*0.25,0,0,Math.PI*2);ctx.fill(); + ctx.fillStyle=tCols[t.shade%4]; + ctx.beginPath();ctx.arc(t.x,t.y-t.sz*0.2,t.sz,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#3a9a3018";ctx.beginPath();ctx.arc(t.x-t.sz*0.25,t.y-t.sz*0.5,t.sz*0.45,0,Math.PI*2);ctx.fill(); + }); + + // ── Keims ── + for(var ki=0;ki0.35){ + ctx.strokeStyle="rgba(255,50,50,"+(k.size*0.6)+")"; + ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(k.x,k.y,r2*2,0,Math.PI*2);ctx.stroke(); + } + } + + // ── Spray particles ── + sprayParticles.forEach(function(p){ + var alpha=p.life*1.5; + ctx.fillStyle=p.color.replace(")",","+alpha+")").replace("rgb","rgba"); + ctx.beginPath();ctx.arc(p.x,p.y,2+p.life*3,0,Math.PI*2);ctx.fill(); + }); + + // ── Buildings ── + drawBldg(ctx,net.lab.x,net.lab.y,"\uD83D\uDD2C","Labor","#304050"); + drawBldg(ctx,net.apo.x,net.apo.y,"\uD83D\uDC8A","Apotheke","#2a4a2a"); + + // Peak + ctx.fillStyle="#9a9a8a"; + ctx.beginPath();ctx.moveTo(net.peak.x-50,net.peak.y+30);ctx.lineTo(net.peak.x-12,net.peak.y-40);ctx.lineTo(net.peak.x+8,net.peak.y-35);ctx.lineTo(net.peak.x+50,net.peak.y+30);ctx.closePath();ctx.fill(); + ctx.fillStyle="#e0e0e0";ctx.beginPath();ctx.moveTo(net.peak.x-18,net.peak.y-22);ctx.lineTo(net.peak.x-12,net.peak.y-40);ctx.lineTo(net.peak.x+8,net.peak.y-35);ctx.lineTo(net.peak.x+14,net.peak.y-20);ctx.closePath();ctx.fill(); + ctx.fillStyle="#fff8";ctx.font="11px sans-serif";ctx.textAlign="center";ctx.fillText("\u26F0\uFE0F Gipfel",net.peak.x,net.peak.y+48); + + // ── Hints (phase 1) ── + if(S.phase===1){ + var hAlpha=S.cfg.hintAlpha; + S.hints.forEach(function(h){ + var sp=Math.sin(frame*0.06+h.phase)*0.5+0.5; + ctx.fillStyle="rgba(255,240,180,"+(sp*hAlpha)+")"; + ctx.beginPath();ctx.arc(h.x,h.y,3+sp*3,0,Math.PI*2);ctx.fill(); + }); + } + + // ── Stones ── + S.stones.forEach(function(st){ + if(st.rolling)return; + if(st.scanned&&!st.isCystine){ + ctx.globalAlpha=0.25;ctx.fillStyle="#555";drawStone(ctx,st.x,st.y,st.shape,st.rollAngle);ctx.globalAlpha=1;return; + } + ctx.fillStyle=st.color; + drawStone(ctx,st.x,st.y,st.shape,st.rollAngle); + if(!st.scanned&&S.phase===1){ + ctx.fillStyle="#fff7";ctx.font="11px sans-serif";ctx.textAlign="center";ctx.fillText("?",st.x,st.y-18); + } + }); + + // Rolling stone + if(S.rollingStone){ + var rs=S.rollingStone; + ctx.fillStyle=rs.color; + drawStone(ctx,rs.x,rs.y,rs.shape,rs.rollAngle); + if(frame%3===0){ + ctx.fillStyle="#a0906840"; + ctx.beginPath();ctx.arc(rs.x+rng(-8,8),rs.y+rng(8,14),rng(2,4),0,Math.PI*2);ctx.fill(); + } + } + + // Parked stone glow + if(S.parkedStone){ + var gl=Math.sin(frame*0.08)*0.3+0.5; + ctx.strokeStyle="rgba(255,200,50,"+gl+")";ctx.lineWidth=2; + ctx.beginPath();ctx.arc(S.parkedStone.x,S.parkedStone.y,STONE_R+8,0,Math.PI*2);ctx.stroke(); + ctx.fillStyle=S.parkedStone.color; + drawStone(ctx,S.parkedStone.x,S.parkedStone.y,S.parkedStone.shape,S.parkedStone.rollAngle); + } + + // ── Player (Sisyphus) ── + var bob=Math.sin(P.frame*2)*1.5; + var sprinting=P.sprinting; + ctx.fillStyle="#00000028";ctx.beginPath();ctx.ellipse(P.x,P.y+20+bob,10,4,0,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#d4a060";ctx.beginPath();ctx.arc(P.x,P.y-14+bob,9,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#5a3a1a";ctx.beginPath();ctx.arc(P.x,P.y-18+bob,6,Math.PI,Math.PI*2);ctx.fill(); + ctx.fillStyle=sprinting?"#7a5535":"#8b6040"; + ctx.fillRect(P.x-6,P.y-5+bob,12,15); + if(S.rollingStone){ + var armDx=Math.cos(P.dir)*10,armDy=Math.sin(P.dir)*10; + ctx.strokeStyle="#d4a060";ctx.lineWidth=3; + ctx.beginPath();ctx.moveTo(P.x,P.y+2+bob);ctx.lineTo(P.x+armDx,P.y+2+bob+armDy);ctx.stroke(); + } + ctx.fillStyle="#4a3020";ctx.fillRect(P.x-7,P.y+3+bob,14,3); + ctx.fillStyle="#6a4830"; + var lOff=Math.sin(P.frame*3)*(sprinting?3:1.5); + ctx.fillRect(P.x-5,P.y+10+bob+lOff,4,8); + ctx.fillRect(P.x+1,P.y+10+bob-lOff,4,8); + if(sprinting&&frame%2===0){ + ctx.fillStyle="#a0906830"; + ctx.beginPath();ctx.arc(P.x-Math.cos(P.dir)*12,P.y-Math.sin(P.dir)*12+20,rng(2,4),0,Math.PI*2);ctx.fill(); + } + + // ── Prompts ── + ctx.font="bold 11px sans-serif";ctx.textAlign="center"; + if(S.phase===1){ + S.stones.forEach(function(st){ + if(!st.scanned&&!st.rolling&&dist(P,st)<55){ + ctx.fillStyle="#ffffffcc";ctx.fillText("[Leertaste] Aufheben",st.x,st.y-22); + } + }); + if(S.rollingStone&&dist(P,net.lab)<65){ + ctx.fillStyle="#00ff88cc";ctx.font="bold 12px sans-serif"; + ctx.fillText("[Leertaste] Scannen!",net.lab.x,net.lab.y-48); + } + } + if(S.phase===2){ + if(S.parkedStone&&!S.rollingStone&&dist(P,S.parkedStone)<55){ + ctx.fillStyle="#ffcc00cc";ctx.fillText("[Leertaste] Aufheben",S.parkedStone.x,S.parkedStone.y-STONE_R-14); + } + if(S.rollingStone&&S.rollingStone.isCystine){ + ctx.fillStyle="#88aaffcc";ctx.font="10px sans-serif";ctx.fillText("[Leertaste] Parken",S.rollingStone.x,S.rollingStone.y-STONE_R-14); + } + if(dist(P,net.apo)<65){ + ctx.fillStyle="#00ff88cc";ctx.fillText("[Leertaste] Auff\u00FCllen",net.apo.x,net.apo.y-48); + } + for(var ki=0;ki0){ + ctx.fillStyle="#ffcc44cc";ctx.font="10px sans-serif"; + ctx.fillText("[Leertaste] Spr\u00FChen",k.x,k.y-k.size*30-12); + break; + } + } + } + + // ── Minimap ── + drawMinimap(ctx,cam); + + ctx.restore(); +} + +function drawStone(ctx,x,y,shape,angle){ + ctx.save();ctx.translate(x,y);ctx.rotate(angle||0); + ctx.beginPath(); + if(shape===0)ctx.ellipse(0,0,STONE_R,STONE_R*0.72,0,0,Math.PI*2); + else if(shape===1){ctx.moveTo(-STONE_R,STONE_R*0.6);ctx.lineTo(-STONE_R*0.5,-STONE_R*0.8);ctx.lineTo(STONE_R*0.8,-STONE_R*0.5);ctx.lineTo(STONE_R,STONE_R*0.6);ctx.closePath();} + else ctx.arc(0,0,STONE_R,0,Math.PI*2); + ctx.fill();ctx.strokeStyle="#00000030";ctx.lineWidth=1;ctx.stroke(); + ctx.fillStyle="#00000015";ctx.beginPath();ctx.arc(STONE_R*0.2,-STONE_R*0.2,STONE_R*0.3,0,Math.PI*2);ctx.fill(); + ctx.fillStyle="#ffffff10";ctx.beginPath();ctx.arc(-STONE_R*0.3,-STONE_R*0.3,STONE_R*0.25,0,Math.PI*2);ctx.fill(); + ctx.restore(); +} + +function drawCrystal(ctx,x,y,r){ + ctx.beginPath(); + var spikes=7; + for(var i=0;i=2)show("victory");else{curLvl=l+1;show("game");}}; + show("transition"); +} + +function showGO(reason){ + sfxLose(); + $("go-r").textContent=reason; + $("go-retry").onclick=function(){show("game")}; + $("go-rst").onclick=function(){curLvl=0;show("game")}; + $("go-quit").onclick=function(){show("title")}; + show("gameover"); +} + +function startGame(){ + initGame(curLvl); frame=0; showTut=true; + var c=LEVELS[curLvl]; + $("tut-h").textContent="Level "+(curLvl+1)+": "+c.name; + $("tut-sc").textContent=c.stones; + $("tut-ov").style.display="flex"; + $("tut-ov").onclick=function(){$("tut-ov").style.display="none";showTut=false;ea()}; + updateHUD(); gameLoop(); +} diff --git a/sde/js/state.js b/sde/js/state.js new file mode 100644 index 0000000..b6f8638 --- /dev/null +++ b/sde/js/state.js @@ -0,0 +1,31 @@ +"use strict"; + +var S=null,keys={},touch={active:false,sx:0,sy:0,dx:0,dy:0}; +var actionQ=false,frame=0,animId=null,curScreen=null,curLvl=0; +var showTut=false,titleHue=0,vicF=0; +var sprayParticles=[]; + +function initGame(li){ + var cfg=LEVELS[li]; + var net=genNet(li); + var veg=genVeg(net); + var st=genStones(net,cfg.stones,li); + sprayParticles=[]; + S={ + cfg:cfg,net:net,veg:veg,stones:st.stones,hints:st.hints, + player:{x:net.start.x,y:net.start.y,dir:0,frame:0,sprinting:false}, + camera:{x:0,y:0}, + phase:1,score:0, + meds:{water:cfg.medsMax,bicarb:cfg.medsMax,thiola:cfg.medsMax}, + medsMax:cfg.medsMax,selMed:0, + kidneyDmg:0, + rollingStone:null, + cystineStone:null, + parkedStone:null, + stoneAloneTimer:0,maxAloneTime:cfg.aloneTime, + keims:[],phase2Timer:0, + msg:"",msgTimer:0,msgAl:false, + stonesScanned:0,time:0,won:false,lost:false, + atPharmacy:false, + }; +} diff --git a/sde/js/utils.js b/sde/js/utils.js new file mode 100644 index 0000000..9220ea1 --- /dev/null +++ b/sde/js/utils.js @@ -0,0 +1,13 @@ +"use strict"; + +var $=function(id){return document.getElementById(id)}; + +function dist(a,b){return Math.sqrt((a.x-b.x)**2+(a.y-b.y)**2)} +function clamp(v,a,b){return Math.max(a,Math.min(b,v))} +function lerp(a,b,t){return a+(b-a)*t} +function rng(a,b){return Math.random()*(b-a)+a} +function lerpCol(a,b,t){ + var pa=[parseInt(a.slice(1,3),16),parseInt(a.slice(3,5),16),parseInt(a.slice(5,7),16)]; + var pb=[parseInt(b.slice(1,3),16),parseInt(b.slice(3,5),16),parseInt(b.slice(5,7),16)]; + return "rgb("+Math.round(lerp(pa[0],pb[0],t))+","+Math.round(lerp(pa[1],pb[1],t))+","+Math.round(lerp(pa[2],pb[2],t))+")"; +} diff --git a/sde/js/world.js b/sde/js/world.js new file mode 100644 index 0000000..1a4b831 --- /dev/null +++ b/sde/js/world.js @@ -0,0 +1,191 @@ +"use strict"; + +// ─── Path Network (orthogonal) ───────── +function genNet(li){ + var cfg=LEVELS[li]; + var cols=6+li*2, rows=8+li*3; + var gapX=100+rng(-10,10), gapY=80+rng(-10,10); + var offX=80, offY=80; + var mapW=offX*2+cols*gapX, mapH=offY*2+rows*gapY; + + var grid=[]; + var nodes=[]; + var nid=0; + + for(var r=0;r<=rows;r++){ + grid[r]=[]; + for(var c=0;c<=cols;c++){ + var keep = r===0||r===rows||c===0||c===cols|| + (r===rows&&c===Math.floor(cols/2))|| + (r===0&&c===Math.floor(cols/2))|| + (r===rows-1&&c<=1)|| + (r===Math.floor(rows*0.4)&&c>=cols-1); + if(!keep && Math.random()<0.35){ + grid[r][c]=null; continue; + } + var jx=(r>0&&r0&&c0&&r0&&c40&&nx40&&ny