Added kidney_labe and Cyste_kid

This commit is contained in:
verboomp
2026-04-16 08:14:20 +02:00
parent aa66c030f8
commit 9cc8ac8cad
40 changed files with 6762 additions and 0 deletions

72
Cyst_Kid/index.html Normal file
View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Cyst-Kid</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="game-container">
<canvas id="gameCanvas"></canvas>
<div id="start-screen" class="overlay">
<div class="overlay-content">
<h1 class="game-title">CYST-KID</h1>
<p class="start-text">STARTTEXT FOLGT NOCH</p>
<div class="controls-info">
<p>Steuerung: Pfeiltasten / WASD</p>
<p>Sprint: Shift</p>
<p>ESC: Zurück zum Start</p>
</div>
<button id="startBtn" class="arcade-btn">START</button>
</div>
</div>
<div id="level-complete-screen" class="overlay" style="display:none">
<div class="overlay-content">
<h2 class="level-text">LEVEL GESCHAFFT!</h2>
<p id="level-score-text"></p>
<button id="nextLevelBtn" class="arcade-btn">NÄCHSTES LEVEL (Enter)</button>
</div>
</div>
<div id="game-over-screen" class="overlay" style="display:none">
<div class="overlay-content">
<h2 class="gameover-text">GAME OVER</h2>
<p class="lose-text">Schade - verloren. Versuche es noch einmal.</p>
<p id="go-score-text"></p>
<button id="restartBtn" class="arcade-btn">NOCHMAL (Enter)</button>
</div>
</div>
<div id="win-screen" class="overlay" style="display:none">
<div class="overlay-content">
<h2 class="win-text">GEWONNEN!</h2>
<p class="win-msg">Herzlichen Glückwunsch - du hast gewonnen.<br>Trage dich mit deinem Namen in das Ranking von Cyst-Kid ein.</p>
<input type="text" id="playerName" placeholder="Dein Name" maxlength="20" class="name-input">
<button id="submitScoreBtn" class="arcade-btn">EINTRAGEN</button>
<div id="ranking-table-container"></div>
<button id="playAgainBtn" class="arcade-btn" style="display:none">NOCHMAL SPIELEN</button>
</div>
</div>
</div>
<!-- Mobile Joystick -->
<div id="touch-controls">
<div id="joystick-base">
<span class="joy-arrow joy-up"></span>
<span class="joy-arrow joy-right"></span>
<span class="joy-arrow joy-down"></span>
<span class="joy-arrow joy-left"></span>
<div id="joystick-knob"></div>
</div>
</div>
<script src="js/constants.js"></script>
<script src="js/sound.js"></script>
<script src="js/game.js"></script>
<script src="js/movement.js"></script>
<script src="js/render.js"></script>
<script src="js/screen-start.js"></script>
<script src="js/screen-level-complete.js"></script>
<script src="js/screen-game-over.js"></script>
<script src="js/screen-win.js"></script>
</body>
</html>

115
Cyst_Kid/js/constants.js Normal file
View File

@@ -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<ROWS&&c>=0&&c<COLS)?MAP[r][c]:-1}
function isPath(r,c){const t=tile(r,c);return t===0||t===2||t===5}
function plOk(r,c){
if(r===T_ROW&&(c<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
];*/

266
Cyst_Kid/js/game.js Normal file
View File

@@ -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)<DEAD)return;
if(Math.abs(dx)>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;r<ROWS;r++)for(let c=0;c<COLS;c++)if(MAP[r][c]===0){this.dots.push({r,c,e:false});this.nDots++}
this.lsc=0;this.lt=0;this.ld=0;this.sup=false;this.supT=0;
this.ateSt=false;this.ateBi=false;this.combD=0;this.combN=this.lv;
this.fx=[];this.lives=3;this.qDir=null;
this.pl={
x:PL0.c*TILE, y:PL0.r*TILE+HUD_TOP,
gc:PL0.c, gr:PL0.r,
dir:'right', moving:false,
spd:BASE_SPD, mouth:0, mouthD:1,
lastFace:'right'
};
this.pains=[];
const st=['chase','ambush','patrol','random'];
PH.forEach((h,i)=>this.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;c<COLS-1;c++)if(isPath(r,c))cs.push({r,c});
for(let i=cs.length-1;i>0;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)<TILE*.8){
if(this.sup&&p.scared){p.eaten=true;this.lsc+=5;this.snd.bite();this.fx.push({x:p.x+TILE/2,y:p.y+TILE/2,co:'#00ff88',t:20,mx:20,tp:'burst'})}
else if(!this.sup){this._die();return}
}
}
}
_die(){
this.lives--;this.snd.boom();
this.fx.push({x:this.pl.x+TILE/2,y:this.pl.y+TILE/2,co:'#FF4444',t:40,mx:40,tp:'exp'});
if(this.lives<=0){this.lsc=0;this.state='over';this.snd.over();setTimeout(()=>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());

213
Cyst_Kid/js/movement.js Normal file
View File

@@ -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;r<ROWS-1;r++)for(let c=1;c<COLS-1;c++)if(isPath(r,c)&&Math.abs(r-this.pl.gr)+Math.abs(c-this.pl.gc)>5)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<this.combN){const rem=this.cysts.filter(c=>!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)<dist(b)?a:b);
case'ambush':{const pdir=this.pl.dir||this.pl.lastFace||'right';const ar=pr+(DR[pdir]||0)*4,ac=pc+(DC[pdir]||0)*4;const ad=d=>{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)<ad(b)?a:b)}
case'patrol':{if(Math.abs(pain.gr-pr)+Math.abs(pain.gc-pc)<8)return ch.reduce((a,b)=>dist(a)<dist(b)?a:b);return ch[0|Math.random()*ch.length]}
default:return ch[0|Math.random()*ch.length];
}
}
});

92
Cyst_Kid/js/render.js Normal file
View File

@@ -0,0 +1,92 @@
// ========== RENDERING ==========
// Extends Game.prototype — must be loaded after game.js
Object.assign(Game.prototype, {
_draw(){
const c=this.cx;
c.fillStyle=CO.bg;c.fillRect(0,0,CW,CH);
c.fillStyle=CO.hud;c.fillRect(0,0,CW,HUD_TOP);c.fillRect(0,CH-HUD_BOT,CW,HUD_BOT);
if(this.state==='start'){this._hud(c);return}
this._maze(c);this._tLbl(c);this._dDots(c);this._dCyst(c);this._dBio(c);this._dPain(c);this._dPl(c);this._dFx(c);this._hud(c);
},
_maze(c){
for(let r=0;r<ROWS;r++)for(let col=0;col<COLS;col++){
const t=MAP[r][col],x=col*TILE,y=r*TILE+HUD_TOP;
if(t===1){
c.fillStyle=CO.wall;c.fillRect(x,y,TILE,TILE);
c.strokeStyle=CO.we;c.lineWidth=1;
const w=(dr,dc)=>{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<this.lives;i++){const lx=16+i*26;c.fillStyle=CO.kid;c.beginPath();c.arc(lx,by,8,.3,Math.PI*2-.3);c.lineTo(lx,by);c.closePath();c.fill();c.fillStyle='#FFF';c.beginPath();c.arc(lx-1,by-3,2.5,0,Math.PI*2);c.fill();c.fillStyle='#000';c.beginPath();c.arc(lx,by-3,1,0,Math.PI*2);c.fill()}
c.fillStyle=CO.bag;c.textAlign='right';c.font='9px "Press Start 2P",monospace';c.fillText('\u2623 x'+Math.max(0,this.combN-this.combD),CW-10,by);
}
});

View File

@@ -0,0 +1,11 @@
// ========== GAME OVER SCREEN ==========
// Extends Game.prototype — must be loaded after game.js
Object.assign(Game.prototype, {
_showGameOver(){
document.getElementById('go-score-text').textContent='Punkte: '+this.sc;
document.getElementById('game-over-screen').style.display='flex';
}
});

View File

@@ -0,0 +1,11 @@
// ========== LEVEL COMPLETE SCREEN ==========
// Extends Game.prototype — must be loaded after game.js
Object.assign(Game.prototype, {
_showLvlDone(){
document.getElementById('level-score-text').textContent='Punkte: '+(this.sc+this.lsc);
document.getElementById('level-complete-screen').style.display='flex';
}
});

View File

@@ -0,0 +1,15 @@
// ========== START SCREEN ==========
// Extends Game.prototype — must be loaded after game.js
Object.assign(Game.prototype, {
_start(){
this.lv=1;this.lives=3;this.sc=0;this.tt=0;this.td=0;
this.state='playing';this._ov(null);this._init();
},
_showStart(){
document.getElementById('start-screen').style.display='flex';
}
});

28
Cyst_Kid/js/screen-win.js Normal file
View File

@@ -0,0 +1,28 @@
// ========== WIN SCREEN ==========
// Extends Game.prototype — must be loaded after game.js
Object.assign(Game.prototype, {
_showWin(){
document.getElementById('submitScoreBtn').style.display='';
document.getElementById('playerName').style.display='';
document.getElementById('playAgainBtn').style.display='none';
document.getElementById('ranking-table-container').innerHTML='';
document.getElementById('win-screen').style.display='flex';
document.getElementById('playerName').focus();
},
_sub(){
const nm=document.getElementById('playerName').value.trim()||'Anonym';
this.rank.push({n:nm,s:this.sc,t:this.tt,d:Math.round(this.td)});
this.rank.sort((a,b)=>{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='<table><tr><th>#</th><th>Name</th><th>Punkte</th><th>Zeit</th></tr>';
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+=`<tr><td>${i+1}</td><td>${r.n}</td><td>${r.s}</td><td>${m}:${s}</td></tr>`});
document.getElementById('ranking-table-container').innerHTML=h+'</table>';
document.getElementById('submitScoreBtn').style.display='none';
document.getElementById('playerName').style.display='none';
document.getElementById('playAgainBtn').style.display='inline-block';
}
});

12
Cyst_Kid/js/sound.js Normal file
View File

@@ -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;i<l;i++)da[i]=(Math.random()*2-1)*Math.pow(1-i/l,2);const s=this.x.createBufferSource();s.buffer=b;const fl=this.x.createBiquadFilter();fl.type=tp||'bandpass';fl.frequency.setValueAtTime(f1,n);if(f2)fl.frequency.exponentialRampToValueAtTime(f2,n+d);fl.Q.value=2;const g=this.x.createGain();g.gain.setValueAtTime(v,n);g.gain.exponentialRampToValueAtTime(.001,n+d);s.connect(fl);fl.connect(g);g.connect(this.x.destination);s.start(n);s.stop(n+d)}
_t(f,f2,d,v,w){if(!this.x)return;const n=this.x.currentTime,o=this.x.createOscillator();o.type=w||'sine';o.frequency.setValueAtTime(f,n);if(f2)o.frequency.exponentialRampToValueAtTime(f2,n+d);const g=this.x.createGain();g.gain.setValueAtTime(v,n);g.gain.exponentialRampToValueAtTime(.001,n+d);o.connect(g);g.connect(this.x.destination);o.start(n);o.stop(n+d)}
bite(){this._n(.05,3500,null,.15,'bandpass')}
boom(){this._n(.3,800,80,.3,'lowpass')}
laser(){this._t(1800,200,.25,.15);this._t(900,100,.2,.1,'square')}
lvlUp(){[523,659,784,1047].forEach((f,i)=>setTimeout(()=>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))}
}

View File

@@ -0,0 +1,872 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cyst-Kid — Level Designer</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0a0a1a;
color: #fff;
font-family: 'Courier New', monospace;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 16px;
gap: 12px;
}
h1 {
font-family: 'Press Start 2P', 'Courier New', monospace;
color: #8B5CF6;
font-size: 18px;
letter-spacing: 2px;
text-shadow: 0 0 12px #8B5CF6;
}
.layout {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
}
/* ---- Canvas ---- */
#canvas-wrap {
position: relative;
cursor: crosshair;
border: 2px solid #8B5CF6;
box-shadow: 0 0 20px rgba(139,92,246,.4);
}
#grid { display: block; }
#overlay { position: absolute; top: 0; left: 0; cursor: crosshair; }
/* ---- Sidebar ---- */
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 200px;
max-width: 240px;
}
.panel {
background: #111133;
border: 1px solid #333366;
border-radius: 6px;
padding: 10px;
}
.panel h3 {
font-size: 10px;
color: #A78BFA;
letter-spacing: 1px;
margin-bottom: 8px;
text-transform: uppercase;
}
/* ---- Palette ---- */
.palette {
display: flex;
flex-direction: column;
gap: 4px;
}
.tile-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border: 2px solid transparent;
border-radius: 4px;
background: #0d0d2a;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 11px;
color: #ccc;
transition: border-color .15s, background .15s;
width: 100%;
text-align: left;
}
.tile-btn:hover { background: #1a1a40; }
.tile-btn.active { border-color: #8B5CF6; background: #1e1e4a; color: #fff; }
.tile-swatch {
width: 18px; height: 18px;
border-radius: 2px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,.2);
}
.tile-key {
margin-left: auto;
font-size: 9px;
color: #666;
background: #222;
padding: 1px 4px;
border-radius: 3px;
}
/* ---- Tool buttons ---- */
.tool-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.tool-btn {
padding: 6px 4px;
font-size: 10px;
font-family: 'Courier New', monospace;
background: #0d0d2a;
border: 2px solid #333366;
border-radius: 4px;
color: #ccc;
cursor: pointer;
text-align: center;
transition: border-color .15s, background .15s;
}
.tool-btn:hover { background: #1a1a40; border-color: #8B5CF6; color: #fff; }
.tool-btn.active { background: #1e1e4a; border-color: #8B5CF6; color: #fff; }
.tool-btn.danger { border-color: #7f1d1d; color: #f87171; }
.tool-btn.danger:hover { background: #3b0000; border-color: #ef4444; }
.tool-btn.success { border-color: #14532d; color: #4ade80; }
.tool-btn.success:hover { background: #003b0e; border-color: #22c55e; }
.tool-btn.full { grid-column: span 2; }
/* ---- Info ---- */
#info-bar {
font-size: 10px;
color: #888;
min-height: 16px;
}
#dot-count {
font-size: 10px;
color: #A78BFA;
}
/* ---- Export modal ---- */
#export-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.75);
z-index: 100;
align-items: center;
justify-content: center;
}
#export-modal.open { display: flex; }
.modal-box {
background: #111133;
border: 2px solid #8B5CF6;
border-radius: 8px;
padding: 20px;
max-width: 720px;
width: 95vw;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 10px;
}
.modal-box h2 { color: #A78BFA; font-size: 13px; letter-spacing: 1px; }
#export-code {
flex: 1;
background: #0a0a1a;
color: #4ade80;
font-family: 'Courier New', monospace;
font-size: 11px;
padding: 12px;
border: 1px solid #333;
border-radius: 4px;
resize: none;
min-height: 300px;
overflow: auto;
}
.modal-actions { display: flex; gap: 8px; }
.modal-close {
padding: 8px 16px;
background: #0d0d2a;
border: 2px solid #333366;
border-radius: 4px;
color: #ccc;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 11px;
}
.modal-close:hover { border-color: #8B5CF6; }
.modal-copy {
padding: 8px 16px;
background: #14532d;
border: 2px solid #22c55e;
border-radius: 4px;
color: #4ade80;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 11px;
}
.modal-copy:hover { background: #166534; }
/* ---- Legend ---- */
.legend { font-size: 9px; color: #666; line-height: 1.8; }
.legend span { color: #888; }
</style>
</head>
<body>
<h1>CYST-KID · LEVEL DESIGNER</h1>
<div class="layout">
<!-- Canvas area -->
<div>
<div id="canvas-wrap">
<canvas id="grid"></canvas>
<canvas id="overlay"></canvas>
</div>
<div style="margin-top:6px; display:flex; justify-content:space-between; padding:0 2px;">
<span id="info-bar">hover over a tile to inspect</span>
<span id="dot-count"></span>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Tile palette -->
<div class="panel">
<h3>Tile-Palette</h3>
<div class="palette" id="palette"></div>
</div>
<!-- Tools -->
<div class="panel">
<h3>Werkzeuge</h3>
<div class="tool-grid">
<button class="tool-btn active" id="tool-paint" onclick="setTool('paint')">✏️ Malen</button>
<button class="tool-btn" id="tool-fill" onclick="setTool('fill')">🪣 Füllen</button>
<button class="tool-btn" id="tool-pick" onclick="setTool('pick')">💉 Picker</button>
<button class="tool-btn" id="tool-line" onclick="setTool('line')">📏 Linie</button>
<button class="tool-btn full" id="tool-rect" onclick="setTool('rect')">⬜ Rechteck</button>
</div>
</div>
<!-- Map size -->
<div class="panel">
<h3>Map-Größe</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:6px; align-items:center; font-size:10px; color:#aaa; margin-bottom:6px;">
<label for="input-cols">Breite (Spalten)</label>
<input id="input-cols" type="number" min="4" max="128"
style="background:#0a0a1a;border:1px solid #333366;color:#fff;padding:3px 6px;border-radius:3px;font-family:monospace;font-size:11px;width:100%">
<label for="input-rows">Höhe (Zeilen)</label>
<input id="input-rows" type="number" min="4" max="128"
style="background:#0a0a1a;border:1px solid #333366;color:#fff;padding:3px 6px;border-radius:3px;font-family:monospace;font-size:11px;width:100%">
</div>
<button class="tool-btn full" style="width:100%" onclick="resizeMap()">↔ Größe anwenden</button>
<div class="legend" style="margin-top:6px">Neue Felder werden mit Wand gefüllt.<br>Verkleinern schneidet ab.</div>
</div>
<!-- Actions -->
<div class="panel">
<h3>Aktionen</h3>
<div class="tool-grid">
<button class="tool-btn" onclick="undo()">↩ Undo</button>
<button class="tool-btn" onclick="redo()">↪ Redo</button>
<button class="tool-btn success full" onclick="openExport()">📋 Exportieren</button>
<button class="tool-btn full" onclick="loadDefault()">↺ Reset Map</button>
<button class="tool-btn danger full" onclick="clearAll()">✕ Alles löschen</button>
</div>
</div>
<!-- Special positions -->
<div class="panel">
<h3>Spezial-Positionen</h3>
<div class="tool-grid">
<button class="tool-btn" onclick="setSpecial('player')">🧑 Spieler-Start</button>
<button class="tool-btn" onclick="setSpecial('ghost')">👻 Geist-Haus</button>
</div>
<div class="legend" style="margin-top:8px">
<span>Cyan ●</span> = Spielerstart<br>
<span>Gold ●</span> = Geist-Startpos
</div>
</div>
<!-- Info -->
<div class="panel">
<h3>Steuerung</h3>
<div class="legend">
<span>LMB</span> = Malen / Tool<br>
<span>RMB</span> = Löschen (→ dot)<br>
<span>Shift+drag</span> = Linie<br>
<span>18</span> = Tile wählen<br>
<span>Ctrl+Z</span> = Undo<br>
<span>Ctrl+Y</span> = Redo<br>
<span>P</span> = Picker<br>
<span>F</span> = Füllen
</div>
</div>
</div>
</div>
<!-- Export modal -->
<div id="export-modal">
<div class="modal-box">
<h2>MAP-EXPORT — in constants.js einfügen</h2>
<textarea id="export-code" readonly spellcheck="false"></textarea>
<div class="modal-actions">
<button class="modal-copy" onclick="copyExport()">Kopieren</button>
<button class="modal-close" onclick="closeExport()">Schließen</button>
</div>
</div>
</div>
<!-- Load constants so we can pull the MAP and colour constants -->
<script src="js/constants.js"></script>
<script>
// ============================================================
// TILE DEFINITIONS
// ============================================================
const TILE_DEFS = [
{ id: 0, label: 'Dot (Punkt)', color: '#333355', dot: '#FFF', key: '1' },
{ id: 1, label: 'Wall (Wand)', color: '#8B5CF6', border: '#A78BFA', key: '2' },
{ id: 2, label: 'Empty (Leer)', color: '#111133', key: '3' },
{ id: 3, label: 'Ghost-Wall', color: '#6D28D9', key: '4' },
{ id: 4, label: 'Ghost-Door', color: '#FFD700', key: '5' },
{ id: 5, label: 'Tunnel', color: '#1a3a1a', border: '#4ade80', key: '6' },
{ id: 8, label: 'Ghost-Inside', color: '#1a1a55', key: '7' },
];
const TILE_ID_TO_DEF = {};
TILE_DEFS.forEach(d => TILE_ID_TO_DEF[d.id] = d);
// ============================================================
// STATE
// ============================================================
const SCALE = 18; // px per tile in editor
let cols = MAP[0].length;
let rows = MAP.length;
// Deep copy of the game MAP
let map = MAP.map(row => [...row]);
// Undo / redo stacks (store JSON snapshots)
let undoStack = [];
let redoStack = [];
// Player start and ghost house positions (mirroring constants)
let playerStart = { c: PL0.c, r: PL0.r };
let ghostHomes = PH.map(p => ({ c: p.c, r: p.r }));
let selectedTile = 1; // currently selected tile id
let currentTool = 'paint';
let isPainting = false;
let lineStart = null;
let rectStart = null;
let specialMode = null; // 'player' | 'ghost' | null
let ghostSetIndex = 0;
// ============================================================
// CANVAS SETUP
// ============================================================
const cvGrid = document.getElementById('grid');
const cvOver = document.getElementById('overlay');
const ctxG = cvGrid.getContext('2d');
const ctxO = cvOver.getContext('2d');
cvGrid.width = cvOver.width = cols * SCALE;
cvGrid.height = cvOver.height = rows * SCALE;
// ============================================================
// BUILD PALETTE
// ============================================================
function buildPalette() {
const el = document.getElementById('palette');
TILE_DEFS.forEach(def => {
const btn = document.createElement('button');
btn.className = 'tile-btn' + (def.id === selectedTile ? ' active' : '');
btn.id = 'tile-btn-' + def.id;
btn.onclick = () => selectTile(def.id);
const swatch = document.createElement('span');
swatch.className = 'tile-swatch';
swatch.style.background = def.color;
if (def.border) swatch.style.borderColor = def.border;
const label = document.createElement('span');
label.textContent = def.label;
const key = document.createElement('span');
key.className = 'tile-key';
key.textContent = def.key;
btn.append(swatch, label, key);
el.appendChild(btn);
});
}
function selectTile(id) {
selectedTile = id;
document.querySelectorAll('.tile-btn').forEach(b => b.classList.remove('active'));
const btn = document.getElementById('tile-btn-' + id);
if (btn) btn.classList.add('active');
}
// ============================================================
// DRAW
// ============================================================
function drawTile(ctx, r, c, tileId) {
const x = c * SCALE, y = r * SCALE;
const def = TILE_ID_TO_DEF[tileId];
if (!def) {
ctx.fillStyle = '#ff00ff';
ctx.fillRect(x, y, SCALE, SCALE);
return;
}
ctx.fillStyle = def.color;
ctx.fillRect(x, y, SCALE, SCALE);
// Wall edge highlight
if (tileId === 1) {
ctx.strokeStyle = '#A78BFA';
ctx.lineWidth = 0.5;
ctx.strokeRect(x + .5, y + .5, SCALE - 1, SCALE - 1);
}
// Dot
if (tileId === 0) {
ctx.fillStyle = '#FFF';
const ds = 3;
ctx.fillRect(x + SCALE / 2 - ds / 2, y + SCALE / 2 - ds / 2, ds, ds);
}
// Ghost door
if (tileId === 4) {
ctx.fillStyle = '#FFD700';
ctx.fillRect(x + 1, y + SCALE * .35, SCALE - 2, SCALE * .3);
}
// Tunnel stripe
if (tileId === 5) {
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 1;
ctx.strokeRect(x + .5, y + .5, SCALE - 1, SCALE - 1);
}
// Ghost wall
if (tileId === 3) {
ctx.strokeStyle = '#9F7AEA';
ctx.lineWidth = 1;
ctx.strokeRect(x + 1, y + 1, SCALE - 2, SCALE - 2);
}
}
function resizeCanvases() {
cvGrid.width = cvOver.width = cols * SCALE;
cvGrid.height = cvOver.height = rows * SCALE;
}
function drawGrid() {
ctxG.clearRect(0, 0, cvGrid.width, cvGrid.height);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
drawTile(ctxG, r, c, map[r][c]);
}
}
// Grid lines (subtle)
ctxG.strokeStyle = 'rgba(255,255,255,0.05)';
ctxG.lineWidth = 0.5;
for (let r = 0; r <= rows; r++) {
ctxG.beginPath();
ctxG.moveTo(0, r * SCALE);
ctxG.lineTo(cols * SCALE, r * SCALE);
ctxG.stroke();
}
for (let c = 0; c <= cols; c++) {
ctxG.beginPath();
ctxG.moveTo(c * SCALE, 0);
ctxG.lineTo(c * SCALE, rows * SCALE);
ctxG.stroke();
}
// Special markers
drawMarker(ctxG, playerStart.r, playerStart.c, '#00DDFF', '★');
ghostHomes.forEach((gh, i) => drawMarker(ctxG, gh.r, gh.c, '#FFD700', (i + 1).toString()));
updateDotCount();
}
function drawMarker(ctx, r, c, color, label) {
const x = c * SCALE + SCALE / 2;
const y = r * SCALE + SCALE / 2;
ctx.save();
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.arc(x, y, SCALE * 0.38, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.fillStyle = '#000';
ctx.font = `bold ${SCALE * 0.5}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, x, y + 1);
ctx.restore();
}
function drawOverlay(r, c, preview) {
ctxO.clearRect(0, 0, cvOver.width, cvOver.height);
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
// Preview tiles (line / rect)
if (preview && preview.length) {
preview.forEach(([pr, pc]) => {
ctxO.save();
ctxO.globalAlpha = 0.55;
drawTile(ctxO, pr, pc, selectedTile);
ctxO.restore();
});
}
// Hover highlight
ctxO.strokeStyle = 'rgba(255,255,255,0.8)';
ctxO.lineWidth = 1.5;
ctxO.strokeRect(c * SCALE + 0.75, r * SCALE + 0.75, SCALE - 1.5, SCALE - 1.5);
}
function updateDotCount() {
let dots = 0;
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
if (map[r][c] === 0) dots++;
document.getElementById('dot-count').textContent = `Dots: ${dots} | ${cols}×${rows}`;
}
// ============================================================
// UNDO / REDO
// ============================================================
function snapshot() {
undoStack.push(JSON.stringify(map));
if (undoStack.length > 100) undoStack.shift();
redoStack = [];
}
function undo() {
if (!undoStack.length) return;
redoStack.push(JSON.stringify(map));
map = JSON.parse(undoStack.pop());
drawGrid();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(JSON.stringify(map));
map = JSON.parse(redoStack.pop());
drawGrid();
}
// ============================================================
// EDIT OPERATIONS
// ============================================================
function paintCell(r, c, tid) {
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
if (map[r][c] === tid) return;
map[r][c] = tid;
drawTile(ctxG, r, c, tid);
}
function floodFill(r, c, newId) {
const oldId = map[r][c];
if (oldId === newId) return;
snapshot();
const stack = [[r, c]];
const visited = new Set();
while (stack.length) {
const [cr, cc] = stack.pop();
const key = cr * cols + cc;
if (visited.has(key)) continue;
if (cr < 0 || cr >= rows || cc < 0 || cc >= cols) continue;
if (map[cr][cc] !== oldId) continue;
visited.add(key);
map[cr][cc] = newId;
stack.push([cr - 1, cc], [cr + 1, cc], [cr, cc - 1], [cr, cc + 1]);
}
drawGrid();
}
function getLineCells(r0, c0, r1, c1) {
const cells = [];
const dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0);
const sr = r0 < r1 ? 1 : -1, sc = c0 < c1 ? 1 : -1;
let err = dr - dc;
let r = r0, c = c0;
while (true) {
cells.push([r, c]);
if (r === r1 && c === c1) break;
const e2 = 2 * err;
if (e2 > -dc) { err -= dc; r += sr; }
if (e2 < dr) { err += dr; c += sc; }
}
return cells;
}
function getRectCells(r0, c0, r1, c1) {
const cells = [];
const rMin = Math.min(r0, r1), rMax = Math.max(r0, r1);
const cMin = Math.min(c0, c1), cMax = Math.max(c0, c1);
for (let r = rMin; r <= rMax; r++)
for (let c = cMin; c <= cMax; c++)
cells.push([r, c]);
return cells;
}
function applyLineCells(cells) {
cells.forEach(([r, c]) => paintCell(r, c, selectedTile));
}
// ============================================================
// TOOLS
// ============================================================
function setTool(t) {
currentTool = t;
lineStart = null; rectStart = null;
['paint', 'fill', 'pick', 'line', 'rect'].forEach(id => {
const el = document.getElementById('tool-' + id);
if (el) el.classList.toggle('active', id === t);
});
}
// ============================================================
// SPECIAL POSITIONS
// ============================================================
function setSpecial(mode) {
specialMode = mode;
ghostSetIndex = 0;
document.getElementById('info-bar').textContent =
mode === 'player'
? 'Klick: Spieler-Startposition setzen'
: 'Klick: Geist-Position 1 von 4 setzen';
}
// ============================================================
// MOUSE / POINTER EVENTS
// ============================================================
function getTilePos(e) {
const rect = cvOver.getBoundingClientRect();
return {
r: Math.floor((e.clientY - rect.top) / SCALE),
c: Math.floor((e.clientX - rect.left) / SCALE)
};
}
cvOver.addEventListener('contextmenu', e => e.preventDefault());
cvOver.addEventListener('mousemove', e => {
const { r, c } = getTilePos(e);
const inBounds = r >= 0 && r < rows && c >= 0 && c < cols;
if (!inBounds) { drawOverlay(-1, -1); return; }
const tDef = TILE_ID_TO_DEF[map[r][c]];
document.getElementById('info-bar').textContent =
`[${r}, ${c}] → ${tDef ? tDef.label : '?'} (id=${map[r][c]})`;
let preview = null;
if (currentTool === 'line' && lineStart) {
preview = getLineCells(lineStart.r, lineStart.c, r, c);
} else if (currentTool === 'rect' && rectStart) {
preview = getRectCells(rectStart.r, rectStart.c, r, c);
}
drawOverlay(r, c, preview);
// Paint drag
if (isPainting && (currentTool === 'paint')) {
const tid = (e.buttons & 2) ? 0 : selectedTile;
paintCell(r, c, tid);
drawTile(ctxG, r, c, map[r][c]);
}
});
cvOver.addEventListener('mousedown', e => {
e.preventDefault();
const { r, c } = getTilePos(e);
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
// Special placement mode
if (specialMode) {
if (specialMode === 'player') {
playerStart = { r, c };
specialMode = null;
document.getElementById('info-bar').textContent = `Spielerstart → [${r}, ${c}]`;
} else if (specialMode === 'ghost') {
ghostHomes[ghostSetIndex] = { r, c };
ghostSetIndex++;
if (ghostSetIndex >= 4) {
specialMode = null;
document.getElementById('info-bar').textContent = 'Alle 4 Geist-Positionen gesetzt.';
} else {
document.getElementById('info-bar').textContent =
`Geist-Position ${ghostSetIndex + 1} von 4 setzen`;
}
}
drawGrid();
return;
}
const isRMB = e.button === 2;
if (currentTool === 'pick') {
selectTile(map[r][c]);
return;
}
if (currentTool === 'fill') {
floodFill(r, c, isRMB ? 0 : selectedTile);
return;
}
if (currentTool === 'line') {
if (!lineStart) {
lineStart = { r, c };
} else {
snapshot();
applyLineCells(getLineCells(lineStart.r, lineStart.c, r, c));
lineStart = null;
drawGrid();
}
return;
}
if (currentTool === 'rect') {
if (!rectStart) {
rectStart = { r, c };
} else {
snapshot();
applyLineCells(getRectCells(rectStart.r, rectStart.c, r, c));
rectStart = null;
drawGrid();
}
return;
}
// Paint tool
snapshot();
isPainting = true;
paintCell(r, c, isRMB ? 0 : selectedTile);
drawGrid();
});
cvOver.addEventListener('mouseup', () => {
if (isPainting) drawGrid();
isPainting = false;
});
cvOver.addEventListener('mouseleave', () => {
ctxO.clearRect(0, 0, cvOver.width, cvOver.height);
isPainting = false;
});
// ============================================================
// KEYBOARD SHORTCUTS
// ============================================================
document.addEventListener('keydown', e => {
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') { e.preventDefault(); undo(); return; }
if (e.key === 'y') { e.preventDefault(); redo(); return; }
}
// Tile shortcuts
TILE_DEFS.forEach(def => { if (e.key === def.key) selectTile(def.id); });
if (e.key === 'p' || e.key === 'P') setTool('pick');
if (e.key === 'f' || e.key === 'F') setTool('fill');
if (e.key === 'l' || e.key === 'L') setTool('line');
if (e.key === 'r' || e.key === 'R') setTool('rect');
if (e.key === 'Escape') { lineStart = null; rectStart = null; specialMode = null; }
});
// ============================================================
// ACTIONS
// ============================================================
function clearAll() {
if (!confirm('Gesamte Map löschen (alle Tiles auf 1 = Wand)?')) return;
snapshot();
map = Array.from({ length: rows }, () => new Array(cols).fill(1));
drawGrid();
}
function loadDefault() {
if (!confirm('Map auf den Original-Stand zurücksetzen?')) return;
snapshot();
map = MAP.map(row => [...row]);
cols = map[0].length;
rows = map.length;
document.getElementById('input-cols').value = cols;
document.getElementById('input-rows').value = rows;
resizeCanvases();
drawGrid();
}
function resizeMap() {
const newCols = Math.max(4, Math.min(128, parseInt(document.getElementById('input-cols').value) || cols));
const newRows = Math.max(4, Math.min(128, parseInt(document.getElementById('input-rows').value) || rows));
if (newCols === cols && newRows === rows) return;
snapshot();
// Build new map: keep existing tiles where they fit, fill new space with wall (1)
const newMap = Array.from({ length: newRows }, (_, r) =>
Array.from({ length: newCols }, (_, c) =>
(r < rows && c < cols) ? map[r][c] : 1
)
);
map = newMap;
cols = newCols;
rows = newRows;
// Clamp special positions to new bounds
playerStart.r = Math.min(playerStart.r, rows - 1);
playerStart.c = Math.min(playerStart.c, cols - 1);
ghostHomes = ghostHomes.map(g => ({
r: Math.min(g.r, rows - 1),
c: Math.min(g.c, cols - 1)
}));
resizeCanvases();
drawGrid();
}
// ============================================================
// EXPORT
// ============================================================
function buildExportCode() {
const lines = [];
lines.push(`const COLS = ${cols};`);
lines.push(`const ROWS = ${rows};`);
lines.push('');
lines.push('/* 0=dot 1=wall 2=empty 3=ghostWall 4=ghostDoor 5=tunnel 8=ghostInside */');
lines.push('const MAP = [');
map.forEach((row, r) => {
lines.push('[' + row.join(',') + '],//' + r);
});
lines.push('];');
lines.push('');
lines.push(`const PL0 = {c:${playerStart.c},r:${playerStart.r}};`);
const ph = ghostHomes.map(g => `{c:${g.c},r:${g.r}}`).join(',');
lines.push(`const PH = [${ph}];`);
return lines.join('\n');
}
function openExport() {
document.getElementById('export-code').value = buildExportCode();
document.getElementById('export-modal').classList.add('open');
}
function closeExport() {
document.getElementById('export-modal').classList.remove('open');
}
function copyExport() {
const ta = document.getElementById('export-code');
ta.select();
navigator.clipboard.writeText(ta.value).then(() => {
const btn = document.querySelector('.modal-copy');
const orig = btn.textContent;
btn.textContent = '✓ Kopiert!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
// Close modal on backdrop click
document.getElementById('export-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeExport();
});
// ============================================================
// BOOTSTRAP
// ============================================================
buildPalette();
document.getElementById('input-cols').value = cols;
document.getElementById('input-rows').value = rows;
drawGrid();
</script>
</body>
</html>

264
Cyst_Kid/style.css Normal file
View File

@@ -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;
}

4
WEB-INF/jboss-web.xml Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<jboss-web>
<context-root>games</context-root>
</jboss-web>

4
WEB-INF/web.xml Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd" version="3.0">
</web-app>

21
index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kidney Lab — MW-56 Game &amp; Watch</title>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div id="app">
<a href="/games/Cyst_Kid/index.html">Cyst Kid</a>
<br><br>
<a href="/games/test/index.html">Kidney Lab</a>
<br><br>
</div>
</body>
</html>

67
kidney_lab/CLAUDE.md Normal file
View File

@@ -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 `<script>` tags in dependency order.
For local development use any static file server, e.g.:
```bash
python3 -m http.server 8080
# then open http://localhost:8080
```
## Architecture
The game is split into seven files. Load order in `index.html` reflects the dependency chain:
```
settings.js → player.js → conveyor.js → lab.js → highscore.js → game.js → intro.js
```
### File responsibilities
| File | Exports | Role |
|---|---|---|
| `settings.js` | `SETTINGS` (frozen object) | All numeric constants and colours. No side-effects. Tweak values here only. |
| `player.js` | `Player` class | Grid position (`col` 0-2, `row` 0-2), inventory, movement cooldown, walk animation. No rendering. |
| `conveyor.js` | `LeftConveyorSystem`, `RightConveyorSystem`, `Good`, `Medication` classes + state enums | Belt physics and item lifecycle. No rendering. |
| `lab.js` | `Lab` class, `LabState` enum | Accepts deposited goods, detects specials, runs analysis timer, fires `onMedicationReady` callback. No rendering. |
| `game.js` | `Game.init(name1, name2)` | Game loop, all canvas rendering, input handling, 2P split-screen logic. Calls `HighScoreScreen.show()` on time-out. |
| `intro.js` | `IntroScreen.show()` | Two-step intro: player count selection → name entry → `Game.init()`. |
| `highscore.js` | `HighScoreScreen.show(names, scores, playerCount)` | Persists top-10 to `localStorage` (`kidneylab_scores`), renders results. Both 1P (scalar) and 2P (array) calls are supported. |
### 2-player split-screen (game.js)
All mutable state is held in per-player arrays (`gPlayers[i]`, `gScores[i]`, etc.). The `RS` proxy object exposes `_pIdx`-indexed getters so `renderOneSide()` can be called twice without duplication.
P2's half is drawn with a mirror transform (`ctx.translate(W, 0); ctx.scale(-1, 1)`). Text inside that transform must go through the `ft(text, x, y)` helper, which applies a local `scale(-1, 1)` to cancel the outer flip.
P2 keyboard input (WASD) has left/right swapped to match the mirrored visual: pressing visual-left (A) calls `move('right')`. The virtual joystick applies the same swap via the `swapLR` flag in `makeJoystick()`.
### Interaction model
Interactions are purely position-based — no action button. `handleInteractionsFor(pIdx)` runs every frame:
- `col === 0` → attempt pickup from left belt at current row
- `col === PLAYER_COLS - 1, row === 2` → deposit all carried goods to lab
- `col === PLAYER_COLS - 1, row === 1` → pick up medication from right belt
- `col === PLAYER_COLS - 1, row === 0` → deliver medication to patient
### Layout constants (all in `settings.js`)
```
x: 0 350 600 900
|←LEFT→ |←MIDDLE → |←RIGHT →|
belts player grid lab/belt/patient
```
Rows are shared vertically: `BELT_ROWS: [155, 300, 445]` (top→bottom, y-centre).
Player pixel position is computed in `Player.pixelX()` — evenly distributed across the middle zone with a 30 px margin from each edge.
### Mobile joystick
`makeJoystick()` in `game.js` builds a virtual joystick per player and appends it to the game screen div. The joystick uses `position: fixed` anchored via `.joy-left` / `.joy-right` CSS classes. It is hidden on non-touch devices via `@media (pointer: coarse)`. Repeated movement while held is driven by `setInterval` at 160 ms; the player's own `moveCooldown` (180 ms) naturally gates actual step rate.

637
kidney_lab/css/styles.css Normal file
View File

@@ -0,0 +1,637 @@
/* ═══════════════════════════════════════════════════════════════
styles.css — Mario Lab (MW-56 Game & Watch clone)
All look-and-feel: layout, typography, colours, animations.
No game logic lives here.
═══════════════════════════════════════════════════════════════ */
/* ── Reset & base ────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #06101e;
--surface: #0d1e38;
--surface2: #10243f;
--border: #1a3a60;
--border2: #2a5a90;
--text: #88ccff;
--text-dim: #4a7aaa;
--text-bright:#cceeff;
--gold: #ffdd44;
--green: #44ffaa;
--red: #ff4455;
--accent: #4499ff;
--font-mono: "Courier New", "Lucida Console", monospace;
}
html, body {
height: 100%;
width: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
overflow: hidden;
user-select: none;
}
/* ── Screen system ───────────────────────────────────────────── */
#app {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.screen {
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
overflow-y: auto;
}
.screen.active {
display: flex;
}
/* ── Game screen: canvas centred ─────────────────────────────── */
#game-screen {
gap: 10px;
background: var(--bg);
}
#game-canvas {
display: block;
border: 2px solid var(--border2);
border-radius: 8px;
box-shadow:
0 0 0 4px var(--surface),
0 0 30px rgba(68,153,255,0.25),
0 0 60px rgba(0,0,0,0.6);
outline: none;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
.controls-legend {
font-size: 11px;
color: var(--text-dim);
text-align: center;
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.controls-legend kbd {
display: inline-block;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: var(--text);
}
/* ── Shared button style ─────────────────────────────────────── */
.btn-primary {
display: inline-block;
background: linear-gradient(135deg, #1a5caa 0%, #0a3070 100%);
color: #cceeff;
border: 2px solid var(--border2);
border-radius: 6px;
padding: 12px 36px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: bold;
letter-spacing: 3px;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
text-transform: uppercase;
box-shadow: 0 0 16px rgba(68,153,255,0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #2a7aee 0%, #1a4aa0 100%);
box-shadow: 0 0 24px rgba(68,153,255,0.55);
}
.btn-primary:active {
transform: scale(0.97);
}
/* ══════════════════════════════════════════════════════════════
INTRO SCREEN
══════════════════════════════════════════════════════════════ */
#intro-screen {
background: radial-gradient(ellipse at center, #0d1e38 0%, #06101e 100%);
}
.intro-inner {
max-width: 560px;
width: 90%;
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 0;
}
/* Logo block */
.intro-logo {
text-align: center;
line-height: 1.1;
}
.logo-gw {
display: block;
font-size: 13px;
letter-spacing: 6px;
color: var(--text-dim);
text-transform: uppercase;
}
.logo-title {
display: block;
font-size: clamp(32px, 8vw, 54px);
font-weight: bold;
letter-spacing: 8px;
color: var(--text-bright);
text-shadow:
0 0 20px rgba(68,153,255,0.7),
0 0 40px rgba(68,153,255,0.4);
animation: title-pulse 3s ease-in-out infinite;
}
.logo-model {
display: block;
font-size: 14px;
letter-spacing: 4px;
color: var(--gold);
margin-top: 4px;
}
@keyframes title-pulse {
0%, 100% { text-shadow: 0 0 20px rgba(68,153,255,0.7), 0 0 40px rgba(68,153,255,0.4); }
50% { text-shadow: 0 0 30px rgba(68,200,255,1), 0 0 60px rgba(68,153,255,0.7); }
}
/* Description */
.intro-description {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
font-size: 13px;
line-height: 1.7;
color: var(--text);
}
.intro-description strong {
color: var(--gold);
}
/* Rules */
.intro-rules {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
}
.intro-rules table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.intro-rules td {
padding: 4px 6px;
color: var(--text);
}
.intro-rules .pts {
text-align: right;
font-weight: bold;
font-size: 13px;
}
.intro-rules .pos { color: var(--green); }
.intro-rules .neg { color: var(--red); }
.rule-note {
margin-top: 10px;
font-size: 11px;
color: var(--text-dim);
line-height: 1.6;
}
.rule-note strong {
color: var(--text-bright);
}
/* Controls hint */
.intro-controls {
font-size: 12px;
color: var(--text-dim);
line-height: 1.8;
text-align: center;
}
.intro-controls kbd {
display: inline-block;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 6px;
font-size: 11px;
color: var(--text);
}
.intro-controls em {
color: var(--accent);
font-style: normal;
}
/* Form */
.intro-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.intro-form label {
font-size: 12px;
letter-spacing: 3px;
color: var(--text-dim);
text-transform: uppercase;
}
.intro-form input[type="text"] {
background: var(--surface2);
border: 2px solid var(--border2);
border-radius: 6px;
color: #ffffff;
font-family: var(--font-mono);
font-size: 20px;
font-weight: bold;
letter-spacing: 4px;
padding: 10px 20px;
text-align: center;
text-transform: uppercase;
width: 240px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.intro-form input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 16px rgba(68,153,255,0.4);
}
.intro-form input[type="text"]::placeholder {
color: var(--text-dim);
letter-spacing: 2px;
}
/* Player count selection */
.player-select {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.player-select-label {
font-size: 11px;
letter-spacing: 4px;
color: var(--text-dim);
text-transform: uppercase;
}
.player-select-btns {
display: flex;
gap: 20px;
}
.btn-player-count {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 110px;
height: 100px;
background: var(--surface2);
border: 2px solid var(--border);
border-radius: 10px;
color: var(--text);
font-family: var(--font-mono);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.1s;
gap: 4px;
}
.btn-player-count:hover {
background: linear-gradient(135deg, #1a5caa 0%, #0a3070 100%);
border-color: var(--accent);
box-shadow: 0 0 20px rgba(68,153,255,0.45);
}
.btn-player-count:active {
transform: scale(0.96);
}
.btn-player-count .pcount {
font-size: 42px;
font-weight: bold;
color: var(--gold);
line-height: 1;
}
.btn-player-count .pcount-label {
font-size: 11px;
letter-spacing: 3px;
color: var(--text-dim);
}
/* Mode badge & name rows (step 2) */
.mode-badge {
font-size: 11px;
letter-spacing: 4px;
color: var(--gold);
background: rgba(255,221,68,0.1);
border: 1px solid rgba(255,221,68,0.3);
border-radius: 4px;
padding: 4px 14px;
margin-bottom: 4px;
}
.name-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.name-row label {
font-size: 11px;
letter-spacing: 3px;
color: var(--text-dim);
text-transform: uppercase;
}
.form-actions {
display: flex;
gap: 14px;
align-items: center;
margin-top: 4px;
}
/* Secondary (back) button */
.btn-secondary {
display: inline-block;
background: transparent;
color: var(--text-dim);
border: 2px solid var(--border);
border-radius: 6px;
padding: 10px 20px;
font-family: var(--font-mono);
font-size: 13px;
letter-spacing: 2px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--border2);
color: var(--text);
}
/* ══════════════════════════════════════════════════════════════
HIGH SCORE SCREEN
══════════════════════════════════════════════════════════════ */
#highscore-screen {
background: radial-gradient(ellipse at center, #0d1e38 0%, #06101e 100%);
}
.hs-inner {
max-width: 580px;
width: 90%;
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px 0;
}
.hs-logo {
text-align: center;
line-height: 1.1;
}
.hs-result {
text-align: center;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 10px;
padding: 20px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.hs-result.hs-gold {
border-color: var(--gold);
box-shadow: 0 0 24px rgba(255,221,68,0.35);
animation: gold-pulse 2s ease-in-out infinite;
}
@keyframes gold-pulse {
0%, 100% { box-shadow: 0 0 24px rgba(255,221,68,0.35); }
50% { box-shadow: 0 0 40px rgba(255,221,68,0.65); }
}
.hs-rank-label {
font-size: 14px;
font-weight: bold;
color: var(--gold);
letter-spacing: 2px;
}
.hs-player {
font-size: 22px;
font-weight: bold;
color: var(--text-bright);
letter-spacing: 5px;
}
.hs-score-display {
font-size: 56px;
font-weight: bold;
color: var(--gold);
line-height: 1;
text-shadow: 0 0 20px rgba(255,221,68,0.6);
}
.hs-score-label {
font-size: 12px;
letter-spacing: 4px;
color: var(--text-dim);
text-transform: uppercase;
}
/* Table */
.hs-table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
overflow-x: auto;
}
.hs-heading {
font-size: 12px;
letter-spacing: 4px;
color: var(--text-dim);
text-align: center;
margin-bottom: 12px;
text-transform: uppercase;
}
.hs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.hs-table th {
text-align: left;
color: var(--text-dim);
font-size: 10px;
letter-spacing: 2px;
padding: 4px 8px 8px;
border-bottom: 1px solid var(--border);
}
.hs-table td {
padding: 6px 8px;
color: var(--text);
border-bottom: 1px solid rgba(26,58,96,0.4);
}
.hs-table .rank-col { color: var(--text-dim); width: 30px; }
.hs-table .name-col { font-weight: bold; letter-spacing: 2px; }
.hs-table .score-col { color: var(--gold); text-align: right; }
.hs-table .date-col { color: var(--text-dim); font-size: 11px; text-align: right; }
.hs-table tr.my-row td {
background: rgba(68,153,255,0.12);
color: #cceeff;
}
.hs-table tr.my-row .score-col { color: var(--gold); }
.hs-actions {
display: flex;
justify-content: center;
}
/* ══════════════════════════════════════════════════════════════
MOBILE JOYSTICK CONTROLS
══════════════════════════════════════════════════════════════ */
.joystick-wrap {
position: fixed;
bottom: 24px;
/* horizontal position is set entirely by .joy-left / .joy-right */
display: none; /* hidden by default on desktop/mouse */
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 100;
}
/* Only show on touch/coarse-pointer devices (phones, tablets) */
@media (pointer: coarse) {
.joystick-wrap { display: flex; }
}
/* Explicit left AND right on every variant so fixed positioning
never falls back to the element's static (centred) position */
.joystick-wrap.joy-right { right: 24px; left: unset; }
.joystick-wrap.joy-left { left: 24px; right: unset; }
.joystick-label {
font-size: 10px;
letter-spacing: 3px;
color: var(--text-dim);
text-transform: uppercase;
}
/* Outer ring */
.joystick-base {
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(13, 30, 56, 0.75);
border: 2px solid var(--border2);
box-shadow: 0 0 24px rgba(0,0,0,0.55), inset 0 0 16px rgba(0,0,0,0.3);
position: relative;
touch-action: none;
-webkit-tap-highlight-color: transparent;
}
.joystick-base.active {
border-color: var(--accent);
box-shadow: 0 0 24px rgba(68,153,255,0.35), inset 0 0 16px rgba(0,0,0,0.3);
}
/* Direction hint rings (subtle) */
.joystick-base::before {
content: '';
position: absolute;
inset: 14px;
border-radius: 50%;
border: 1px dashed rgba(68,153,255,0.18);
pointer-events: none;
}
/* Draggable knob */
.joystick-knob {
width: 48px;
height: 48px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #4499ff, #1a4aa0);
border: 2px solid var(--accent);
box-shadow: 0 0 14px rgba(68,153,255,0.55);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
transition: box-shadow 0.1s;
will-change: transform;
}
.joystick-base.active .joystick-knob {
box-shadow: 0 0 22px rgba(68,153,255,0.85);
}
/* ── Scrollbar styling ───────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border2); }

View File

@@ -0,0 +1,40 @@
# Kidney Lab MW-56
> Dieses Spiel ist lose an das **Game & Watch MW-56 Mario Bros.** von Nintendo aus dem Jahr **1983** angelehnt.
> Wer das Original in Aktion sehen möchte, findet es hier: [Game & Watch MW-56 auf YouTube](https://www.youtube.com/watch?v=ajWf49au8mo)
## Das Spiel
Du schlüpfst in die Rolle eines Arztes, der in einem hektischen Labor arbeitet. Auf drei Förderbändern rollen ununterbrochen Nieren heran und es liegt an dir, sie rechtzeitig aufzusammeln, bevor sie vom Band fallen und verloren gehen.
Trage die Nieren zum Labor am rechten Bildschirmrand und liefere sie dort zur Analyse ab. Je mehr Nieren du erfolgreich abgibst, desto höher dein Punktestand. Aber Vorsicht: Lässt du eine Niere fallen, kostet dich das wertvolle Punkte.
## Die Besonderheit
Gelegentlich befindet sich unter den Nieren eine ganz besondere eine Spenderniere mit außergewöhnlichen Eigenschaften. Gibt der Arzt eine solche Niere ins Labor ab, beginnt dort sofort eine Analyse. Nach kurzer Zeit erscheint auf dem rechten Förderband ein spezielles Medikament. Hole es ab und bringe es schnell zum Patienten oben rechts im Bild er leidet und wartet dringend auf Linderung. Eine erfolgreiche Lieferung wird mit satten fünf Bonuspunkten belohnt.
## Punktewertung
| Aktion | Punkte |
|---|---|
| Niere ins Labor gebracht | +1 |
| Medikament zum Patienten gebracht | +5 |
| Niere fallen lassen | 1 |
| Besondere Niere fallen lassen | 3 |
## Spielregeln
- Der Arzt kann maximal **5 Nieren** gleichzeitig tragen.
- Jede siebte Niere ist im Durchschnitt eine besondere Spenderniere.
- Du hast **3 Minuten** Zeit nutze sie klug!
## Mehrspieler-Modus
Kidney Lab kann auch zu zweit gespielt werden. Beide Spieler steuern ihren eigenen Arzt auf einem gespiegelten Spielfeld und sammeln unabhängig voneinander Punkte. Am Ende gewinnt, wer die meisten Punkte erzielt hat Teamwork ist erlaubt, der Ruhm gehört aber dem Besten!
## Steuerung
| Gerät | Spieler 1 | Spieler 2 |
|---|---|---|
| Tastatur | Pfeiltasten | W / A / S / D |
| Touchscreen | Joystick rechts unten | Joystick links unten |

50
kidney_lab/index.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kidney Lab — MW-56 Game &amp; Watch</title>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div id="app">
<!-- ── Intro screen ─────────────────────────────────────── -->
<div id="intro-screen" class="screen active"></div>
<!-- ── Game screen (canvas injected by game.js) ─────────── -->
<div id="game-screen" class="screen"></div>
<!-- ── High-score screen ────────────────────────────────── -->
<div id="highscore-screen" class="screen"></div>
</div>
<!--
Script load order matters — each file depends only on what
was loaded before it:
settings → (no deps)
player → settings
conveyor → settings
lab → settings
game → settings + player + conveyor + lab + highscore
intro → game
highscore → intro (back button)
We load highscore before intro so game.js can call HighScoreScreen.show().
intro.js is last so it can call Game.init() immediately on boot.
-->
<script src="js/settings.js"></script>
<script src="js/player.js"></script>
<script src="js/conveyor.js"></script>
<script src="js/lab.js"></script>
<script src="js/highscore.js"></script>
<script src="js/game.js"></script>
<script src="js/intro.js"></script>
<script>
// Boot: show the intro screen.
IntroScreen.show();
</script>
</body>
</html>

200
kidney_lab/js/conveyor.js Normal file
View File

@@ -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
);
}
}

1080
kidney_lab/js/game.js Normal file

File diff suppressed because it is too large Load Diff

144
kidney_lab/js/highscore.js Normal file
View File

@@ -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 `
<div class="hs-result ${isTop ? 'hs-gold' : ''}">
<div class="hs-rank-label">${rankLabel(myRank)}</div>
<div class="hs-player">${playerName.toUpperCase()}</div>
<div class="hs-score-display">${finalScore >= 0 ? finalScore : 0}</div>
<div class="hs-score-label">POINTS</div>
</div>`;
}
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 `<tr class="${isMe ? 'my-row' : ''}">
<td class="rank-col">${i + 1}</td>
<td class="name-col">${s.name}</td>
<td class="score-col">${s.score}</td>
<td class="date-col">${s.date}</td>
</tr>`;
}).join('');
return `
<div class="hs-table-wrap">
<h2 class="hs-heading">HALL OF FAME</h2>
<table class="hs-table">
<thead><tr><th>#</th><th>NAME</th><th>SCORE</th><th>DATE</th></tr></thead>
<tbody>${rowsHTML}</tbody>
</table>
</div>`;
}
/* ── 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
? `<div style="display:flex;gap:16px;justify-content:center;flex-wrap:wrap;">${cardsHTML}</div>`
: cardsHTML;
el.innerHTML = `
<div class="hs-inner">
<div class="hs-logo">
<span class="logo-gw">GAME &amp; WATCH</span>
<span class="logo-title">KIDNEY LAB</span>
</div>
${cardsWrap}
${tableHTML(leaderboard, nameArr, scoreArr)}
<div class="hs-actions">
<button id="back-btn" class="btn-primary">PLAY AGAIN</button>
</div>
</div>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
IntroScreen.show();
});
}
return { show };
})();

140
kidney_lab/js/intro.js Normal file
View File

@@ -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 `
<div class="intro-logo">
<span class="logo-gw">GAME &amp; WATCH</span>
<span class="logo-title">KIDNEY LAB</span>
<span class="logo-model">MW-56</span>
</div>
<div class="intro-description">
<p>Help <strong>the Doctor</strong> collect kidneys from the supply belts
and deliver them to the laboratory for analysis.</p>
<p>Bring special medication to the patient to earn bonus points!</p>
</div>
<div class="intro-rules">
<table>
<tr><td>Kidney delivered to lab</td><td class="pts pos">+1 pt</td></tr>
<tr><td>Special med to patient</td><td class="pts pos">+5 pts</td></tr>
<tr><td>Kidney dropped</td><td class="pts neg">1 pt</td></tr>
<tr><td>Special kidney dropped</td><td class="pts neg">3 pts</td></tr>
</table>
<p class="rule-note">Carry up to <strong>5 kidneys</strong> · 1 in 7 chance is special · <strong>3 minutes</strong> on the clock</p>
</div>
<div class="intro-controls">
<p>Move with <kbd>↑↓←→</kbd> or <kbd>W A S D</kbd></p>
<p>Stand at <em>column&nbsp;0</em> (left) to collect · at <em>column&nbsp;3</em> (right) to deposit / pickup / deliver</p>
</div>
`;
}
/* ── Step 1: player count selection ───────────────────────── */
function renderStep1() {
const el = document.getElementById('intro-screen');
el.innerHTML = `
<div class="intro-inner">
${headerHTML()}
<div class="player-select">
<p class="player-select-label">SELECT NUMBER OF PLAYERS</p>
<div class="player-select-btns">
<button class="btn-player-count" id="btn-1p">
<span class="pcount">1</span>
<span class="pcount-label">PLAYER</span>
</button>
<button class="btn-player-count" id="btn-2p">
<span class="pcount">2</span>
<span class="pcount-label">PLAYERS</span>
</button>
</div>
</div>
</div>
`;
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
? `
<div class="name-row">
<label for="name-p1">Player 1 Name</label>
<input type="text" id="name-p1" maxlength="12"
placeholder="DOCTOR" autocomplete="off" spellcheck="false"/>
</div>
<div class="name-row">
<label for="name-p2">Player 2 Name</label>
<input type="text" id="name-p2" maxlength="12"
placeholder="NURSE" autocomplete="off" spellcheck="false"/>
</div>`
: `
<div class="name-row">
<label for="name-p1">Your Name</label>
<input type="text" id="name-p1" maxlength="12"
placeholder="DOCTOR" autocomplete="off" spellcheck="false"/>
</div>`;
el.innerHTML = `
<div class="intro-inner">
${headerHTML()}
<div class="intro-form">
<div class="mode-badge">${playerCount === 2 ? '2 PLAYER MODE' : '1 PLAYER MODE'}</div>
${nameFields}
<div class="form-actions">
<button class="btn-secondary" id="back-btn">← BACK</button>
<button class="btn-primary" id="start-btn">START GAME</button>
</div>
</div>
</div>
`;
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 };
})();

84
kidney_lab/js/lab.js Normal file
View File

@@ -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 01 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;
}
}
}
}
}

140
kidney_lab/js/player.js Normal file
View File

@@ -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;
}
}
}

101
kidney_lab/js/settings.js Normal file
View File

@@ -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)',
});

24
kidney_lab/readme.txt Normal file
View File

@@ -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.

970
sde/game.js Normal file
View File

@@ -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&&r<rows&&c>0&&c<cols)?rng(-12,12):0;
var jy=(r>0&&r<rows&&c>0&&c<cols)?rng(-8,8):0;
var nd={x:offX+c*gapX+jx, y:offY+r*gapY+jy, id:nid, r:r, c:c, role:"path"};
nodes.push(nd);
grid[r][c]=nd;
nid++;
}
}
// Edges: connect adjacent grid cells (horizontal + vertical only)
var edges=[];
var edgeSet={};
function addEdge(a,b){
if(!a||!b)return;
var k=Math.min(a.id,b.id)+"-"+Math.max(a.id,b.id);
if(edgeSet[k])return;
edgeSet[k]=true;
edges.push([a.id,b.id]);
}
for(var r=0;r<=rows;r++){
for(var c=0;c<=cols;c++){
if(!grid[r][c])continue;
// Right neighbor
if(c<cols&&grid[r][c+1]) addEdge(grid[r][c],grid[r][c+1]);
// Down neighbor
if(r<rows&&grid[r+1]&&grid[r+1][c]) addEdge(grid[r][c],grid[r+1][c]);
}
}
// Add some dead-ends by extending from random edge nodes
for(var d=0;d<2+li;d++){
var src=nodes[Math.floor(Math.random()*nodes.length)];
var dir=Math.floor(Math.random()*4); // 0=right,1=down,2=left,3=up
var dx=[gapX,0,-gapX,0][dir], dy=[0,gapY,0,-gapY][dir];
var nx=src.x+dx+rng(-8,8), ny=src.y+dy+rng(-8,8);
if(nx>40&&nx<mapW-40&&ny>40&&ny<mapH-40){
var dn={x:nx,y:ny,id:nid,role:"deadend"};
nodes.push(dn); nid++;
edges.push([src.id,dn.id]);
}
}
// Ensure connectivity: BFS from start, connect isolated components
var startC=Math.floor(cols/2);
var startN=grid[rows][startC];
if(!startN){
// Find closest in bottom row
for(var cc=0;cc<=cols;cc++){
if(grid[rows][cc]){startN=grid[rows][cc];break;}
}
}
// Simple BFS
var visited={};
var queue=[startN.id];
visited[startN.id]=true;
var adj={};
edges.forEach(function(e){
if(!adj[e[0]])adj[e[0]]=[];
if(!adj[e[1]])adj[e[1]]=[];
adj[e[0]].push(e[1]);
adj[e[1]].push(e[0]);
});
while(queue.length){
var cur=queue.shift();
(adj[cur]||[]).forEach(function(nb){
if(!visited[nb]){visited[nb]=true;queue.push(nb);}
});
}
// Connect unvisited nodes to nearest visited
nodes.forEach(function(n){
if(visited[n.id])return;
var best=null,bd=Infinity;
nodes.forEach(function(m){
if(!visited[m.id])return;
var d=dist(n,m);
if(d<bd){bd=d;best=m;}
});
if(best){
edges.push([n.id,best.id]);
if(!adj[n.id])adj[n.id]=[];
if(!adj[best.id])adj[best.id]=[];
adj[n.id].push(best.id);
adj[best.id].push(n.id);
visited[n.id]=true;
}
});
// Key locations
var peakC=Math.floor(cols/2);
var peakN=grid[0][peakC]||nodes[0];
var labN=grid[rows-1]?grid[rows-1][0]||grid[rows][0]||nodes[1]:nodes[1];
// If labN is same as startN, offset
if(labN.id===startN.id){
for(var ci=0;ci<=cols;ci++){
if(grid[rows]&&grid[rows][ci]&&grid[rows][ci].id!==startN.id){labN=grid[rows][ci];break;}
}
}
var apoRow=Math.floor(rows*0.4);
var apoN=grid[apoRow]?grid[apoRow][cols]||grid[apoRow][cols-1]||nodes[3]:nodes[3];
peakN.role="peak"; startN.role="start"; labN.role="lab"; apoN.role="apo";
return {nodes:nodes,edges:edges,mapW:mapW,mapH:mapH,
peak:peakN,start:startN,lab:labN,apo:apoN};
}
// ─── Vegetation ─────────
function genVeg(net){
var trees=[],bushes=[];
// Pre-compute path segments for distance checks
var segs=net.edges.map(function(e){return{a:net.nodes[e[0]],b:net.nodes[e[1]]};});
function nearPath(x,y,minD){
for(var i=0;i<segs.length;i++){
var s=segs[i],ax=s.a.x,ay=s.a.y,bx=s.b.x,by=s.b.y;
var dx=bx-ax,dy=by-ay,l2=dx*dx+dy*dy;
var t=l2===0?0:clamp(((x-ax)*dx+(y-ay)*dy)/l2,0,1);
var d=Math.sqrt((x-(ax+t*dx))**2+(y-(ay+t*dy))**2);
if(d<minD)return true;
}
return false;
}
for(var i=0;i<500;i++){
var vx=rng(15,net.mapW-15), vy=rng(15,net.mapH-15);
if(nearPath(vx,vy,45))continue;
if(Math.random()<0.5){
trees.push({x:vx,y:vy,sz:rng(14,30),shade:Math.floor(rng(0,4)),trunk:rng(10,18)});
}else{
bushes.push({x:vx,y:vy,sz:rng(6,16),shade:Math.floor(rng(0,3))});
}
}
return{trees:trees,bushes:bushes};
}
// ─── Stones on paths ────
function genStones(net,count,li){
var stones=[];
var cIdx=Math.floor(Math.random()*count);
var segs=net.edges.map(function(e){return{a:net.nodes[e[0]],b:net.nodes[e[1]]};});
for(var i=0;i<count;i++){
var pt,tries=0;
do{
var s=segs[Math.floor(Math.random()*segs.length)];
var t=rng(0.2,0.8);
pt={x:lerp(s.a.x,s.b.x,t),y:lerp(s.a.y,s.b.y,t)};
tries++;
}while(tries<100&&(
dist(pt,net.lab)<70||dist(pt,net.start)<60||dist(pt,net.peak)<70||dist(pt,net.apo)<70||
stones.some(function(s2){return dist(pt,s2)<55;})
));
var isC=i===cIdx;
stones.push({id:i,x:pt.x,y:pt.y,isCystine:isC,
color:isC?CYSTINE_COL:STONE_COLS[i%STONE_COLS.length],
scanned:false,rolling:false,shape:Math.floor(Math.random()*3),
rollAngle:0});
}
// Hints
var cs=stones[cIdx];
var hints=[];
for(var h=0;h<3+li;h++){
hints.push({x:cs.x+rng(-65,65),y:cs.y+rng(-65,65),phase:rng(0,Math.PI*2)});
}
return{stones:stones,hints:hints};
}
// ─── Closest point on path network ────
function closestOnPaths(px,py,edges,nodes){
var best=Infinity,bx=px,by=py;
for(var i=0;i<edges.length;i++){
var a=nodes[edges[i][0]],b=nodes[edges[i][1]];
var dx=b.x-a.x,dy=b.y-a.y,l2=dx*dx+dy*dy;
var t=l2===0?0:clamp(((px-a.x)*dx+(py-a.y)*dy)/l2,0,1);
var cx=a.x+t*dx,cy=a.y+t*dy;
var d=(px-cx)**2+(py-cy)**2;
if(d<best){best=d;bx=cx;by=cy;}
}
return{x:bx,y:by,d:Math.sqrt(best)};
}
// ─── State ─────────────
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=[]; // visual spray effect
var $=function(id){return document.getElementById(id)};
// ─── Init Game ─────────
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, // stone being pushed
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,
};
}
// ─── Screens ───────────
function hideAll(){
["screen-title","screen-game","screen-gameover","screen-transition","screen-victory"].forEach(function(id){$(id).style.display="none"});
if(animId){cancelAnimationFrame(animId);animId=null;}
}
function show(n){
hideAll();curScreen=n;$("screen-"+n).style.display="flex";
if(n==="title")runTitle();
if(n==="victory")runVic();
if(n==="game")startGame();
}
function runTitle(){
titleHue=0;
(function t(){if(curScreen!=="title")return;titleHue=(titleHue+1)%360;
var e=$("title-glow");if(e)e.style.background="radial-gradient(circle at 50% 30%,hsla("+titleHue+",40%,30%,0.15) 0%,transparent 60%)";
animId=requestAnimationFrame(t)})();
}
function runVic(){
vicF=0;
(function t(){if(curScreen!=="victory")return;vicF+=0.05;
var y=Math.sin(vicF)*10;
var g=$("v-guru"),s=$("v-stone2");
if(g)g.style.transform="translateY("+y+"px)";
if(s)s.style.transform="translateY("+Math.sin(vicF+1)*6+"px)";
animId=requestAnimationFrame(t)})();
}
function showTrans(l){
var c=LEVELS[l];
$("tr-ln").textContent="\u201E"+c.name+"\u201C bezwungen";
$("tr-t").textContent=c.title;
$("tr-btn").textContent=l<2?"N\u00E4chstes Level":"Zum Finale";
$("tr-btn").onclick=function(){if(l>=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;ki<S.keims.length;ki++){
var k=S.keims[ki];
k.size+=S.cfg.keimGrow*(1+S.phase2Timer*0.003);
k.size=Math.min(k.size,1.0);
k.pulse=(k.pulse||0)+0.04;
}
// Spawn keims on paths
var spawnRate=0.006+curLvl*0.003;
if(Math.random()<spawnRate){
// Random point on path, biased toward peak direction
var segs=S.net.edges;
var si=Math.floor(Math.random()*segs.length);
var ea2=S.net.nodes[segs[si][0]], eb=S.net.nodes[segs[si][1]];
var t2=rng(0.15,0.85);
var kp={x:lerp(ea2.x,eb.x,t2),y:lerp(ea2.y,eb.y,t2)};
// Don't spawn too close to player, pharmacy, lab
if(dist(kp,S.player)>100&&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<S.keims.length;ki++)totK+=S.keims[ki].size;
S.kidneyDmg=Math.min(100,totK*2);
// Stone alone timer (pauses at pharmacy)
if(S.parkedStone&&!S.rollingStone){
if(!S.atPharmacy){
S.stoneAloneTimer+=dt;
}
}else{
S.stoneAloneTimer=Math.max(0,S.stoneAloneTimer-dt*0.8);
}
// Lose
if(S.kidneyDmg>=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&&d<bd){best=st;bd=d;}
});
if(best){
S.rollingStone=best;best.rolling=true;
setMsg("Stein aufgehoben! Rolle ihn zum Labor \uD83D\uDD2C",2.5);
sfxPick();
}
}else{
// At lab? scan
if(dist(P,S.net.lab)<65){
var rs=S.rollingStone;
rs.scanned=true;S.stonesScanned++;
if(rs.isCystine){
setMsg("\u26A0\uFE0F ZYSTINSTEIN ERKANNT! Das Tal erwacht!",3.5,true);
sfxPos();
S.cystineStone=rs;
S.phase=2;S.score+=200;
// Player keeps the stone! (takes it from lab)
// Spawn initial keims
for(var i=0;i<3+curLvl*2;i++){
var segs=S.net.edges;
var si=Math.floor(Math.random()*segs.length);
var ea2=S.net.nodes[segs[si][0]],eb=S.net.nodes[segs[si][1]];
var t2=rng(0.2,0.8);
S.keims.push({x:lerp(ea2.x,eb.x,t2),y:lerp(ea2.y,eb.y,t2),size:0.08,id:Date.now()+i,pulse:0});
}
}else{
setMsg("Negativ \u2013 kein Zystinstein. Suche weiter!",2);
sfxNeg();
rs.rolling=false;
S.rollingStone=null;
S.score+=20;
}
}else{
// Drop stone
S.rollingStone.rolling=false;
S.rollingStone.x=P.x;S.rollingStone.y=P.y;
S.rollingStone=null;
setMsg("Stein abgelegt.",1);
}
}
}
// ── Phase 2 ──
if(S.phase===2){
// Pick up parked stone
if(!S.rollingStone&&S.parkedStone&&dist(P,S.parkedStone)<55){
S.rollingStone=S.parkedStone;S.parkedStone=null;
S.stoneAloneTimer=0;
setMsg("Zystinstein aufgenommen! Weiter zum Gipfel!",1.5);
sfxPick();
return;
}
// Park stone
if(S.rollingStone&&S.rollingStone.isCystine&&!(S.parkedStone)){
S.parkedStone=S.rollingStone;
S.parkedStone.x=P.x;S.parkedStone.y=P.y;
S.parkedStone.rolling=false;
S.rollingStone=null;
S.stoneAloneTimer=0;
setMsg("Stein geparkt. K\u00FCmmere dich um die Keime!",1.5);
return;
}
// Pharmacy refill
if(dist(P,S.net.apo)<65){
var mid=MEDS[S.selMed].id;
if(S.meds[mid]<S.medsMax){
S.meds[mid]=S.medsMax;
var others=MEDS.filter(function(_,i){return i!==S.selMed});
others.sort(function(a,b){return S.meds[a.id]-S.meds[b.id]});
S.meds[others[0].id]=S.medsMax;
setMsg(MEDS[S.selMed].name+" & "+others[0].name+" aufgef\u00FCllt!",1.5);
sfxRefill();S.score+=10;
return;
}
}
// Spray meds on keims
var mid2=MEDS[S.selMed].id;
if(S.meds[mid2]>0){
var hit=false;
for(var ki=0;ki<S.keims.length;ki++){
var k=S.keims[ki];
if(dist(P,k)<70){
var shrink=S.cfg.keimShrink*MEDS[S.selMed].pow;
k.size-=shrink;
hit=true;
// Spray particles
for(var pi=0;pi<6;pi++){
sprayParticles.push({
x:k.x+rng(-8,8),y:k.y+rng(-8,8),
vx:rng(-40,40),vy:rng(-40,40),
life:rng(0.3,0.7),
color:MEDS[S.selMed].color
});
}
}
}
if(hit){
S.meds[mid2]=Math.max(0,S.meds[mid2]-2.5);
sfxSpray();
}
S.keims=S.keims.filter(function(k){return k.size>0.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;ki<S.keims.length;ki++){
var k=S.keims[ki];
var sz=k.size*30;
var pulse=Math.sin(k.pulse)*0.12+1;
var r2=sz*pulse;
// Red aura glow
var auraA=0.08+k.size*0.25;
ctx.fillStyle="rgba(220,40,40,"+auraA+")";
ctx.beginPath();ctx.arc(k.x,k.y,r2*2.5,0,Math.PI*2);ctx.fill();
// Crystal body (yellow/amber)
ctx.fillStyle="rgba(220,200,50,"+(0.5+k.size*0.4)+")";
drawCrystal(ctx,k.x,k.y,r2);
// Inner bright core
ctx.fillStyle="rgba(255,240,100,"+(0.3+k.size*0.3)+")";
drawCrystal(ctx,k.x,k.y,r2*0.4);
// Danger ring for large keims
if(k.size>0.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;ki<S.keims.length;ki++){
var k=S.keims[ki];
if(dist(P,k)<70&&S.meds[MEDS[S.selMed].id]>0){
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<spikes;i++){
var a=(i/spikes)*Math.PI*2-Math.PI/2;
var outerR=r*(0.6+(i%2)*0.4);
var px=x+Math.cos(a)*outerR,py=y+Math.sin(a)*outerR;
if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
var a2=((i+0.5)/spikes)*Math.PI*2-Math.PI/2;
ctx.lineTo(x+Math.cos(a2)*r*0.3,y+Math.sin(a2)*r*0.3);
}
ctx.closePath();ctx.fill();
}
function drawBldg(ctx,x,y,emoji,label,col){
ctx.fillStyle="#00000025";ctx.beginPath();ctx.ellipse(x,y+28,32,8,0,0,Math.PI*2);ctx.fill();
ctx.fillStyle=col;ctx.fillRect(x-28,y-22,56,44);
ctx.fillStyle=col+"cc";ctx.beginPath();ctx.moveTo(x-32,y-22);ctx.lineTo(x,y-38);ctx.lineTo(x+32,y-22);ctx.closePath();ctx.fill();
ctx.fillStyle="#1a1a1a";ctx.fillRect(x-6,y+6,12,16);
ctx.font="22px sans-serif";ctx.textAlign="center";ctx.fillText(emoji,x,y+2);
ctx.fillStyle="#ffffffbb";ctx.font="bold 10px sans-serif";ctx.fillText(label,x,y+40);
}
function drawMinimap(ctx,cam){
var mmW=130,mmH=100,mmX=cam.x+CW-mmW-10,mmY=cam.y+10;
var sx=mmW/S.net.mapW,sy=mmH/S.net.mapH;
ctx.fillStyle="rgba(0,0,0,0.55)";ctx.fillRect(mmX-2,mmY-2,mmW+4,mmH+4);
ctx.fillStyle="rgba(40,60,30,0.8)";ctx.fillRect(mmX,mmY,mmW,mmH);
// Paths
ctx.strokeStyle="#9a8a6a50";ctx.lineWidth=1.5;
S.net.edges.forEach(function(e){
var a=S.net.nodes[e[0]],b=S.net.nodes[e[1]];
ctx.beginPath();ctx.moveTo(mmX+a.x*sx,mmY+a.y*sy);ctx.lineTo(mmX+b.x*sx,mmY+b.y*sy);ctx.stroke();
});
// Buildings
ctx.fillStyle="#4488ff";ctx.fillRect(mmX+S.net.lab.x*sx-2,mmY+S.net.lab.y*sy-2,5,5);
ctx.fillStyle="#44dd44";ctx.fillRect(mmX+S.net.apo.x*sx-2,mmY+S.net.apo.y*sy-2,5,5);
ctx.fillStyle="#ffffff";ctx.fillRect(mmX+S.net.peak.x*sx-2,mmY+S.net.peak.y*sy-2,5,5);
// Keims
S.keims.forEach(function(k){
ctx.fillStyle="rgba(220,200,50,"+(0.4+k.size*0.5)+")";
ctx.beginPath();ctx.arc(mmX+k.x*sx,mmY+k.y*sy,1.5+k.size*2,0,Math.PI*2);ctx.fill();
});
// Parked stone
if(S.parkedStone){ctx.fillStyle="#ffcc00";ctx.fillRect(mmX+S.parkedStone.x*sx-2,mmY+S.parkedStone.y*sy-2,4,4);}
// Player
ctx.fillStyle="#ff4444";ctx.beginPath();ctx.arc(mmX+S.player.x*sx,mmY+S.player.y*sy,3,0,Math.PI*2);ctx.fill();
// Viewport
ctx.strokeStyle="#ffffff35";ctx.lineWidth=1;ctx.strokeRect(mmX+cam.x*sx,mmY+cam.y*sy,CW*sx,CH*sy);
// Labels
ctx.fillStyle="#ffffff55";ctx.font="7px sans-serif";ctx.textAlign="left";
ctx.fillText("Lab",mmX+S.net.lab.x*sx+5,mmY+S.net.lab.y*sy+3);
ctx.fillText("Apo",mmX+S.net.apo.x*sx+5,mmY+S.net.apo.y*sy+3);
ctx.fillText("Gipfel",mmX+S.net.peak.x*sx+5,mmY+S.net.peak.y*sy+3);
}
// ─── Init ──────────────
show("title");
})();

130
sde/index.html Normal file
View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Stein der Erinnerung Die Sisyphus-Saga der Zystinurie</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- TITLE -->
<div id="screen-title" class="screen screen--title">
<div id="title-glow" class="title-glow"></div>
<div class="title-icon">🪨</div>
<h1 class="title-heading">Stein der Erinnerung</h1>
<p class="title-sub">Die Sisyphus-Saga der Zystinurie</p>
<button id="btn-start" class="btn">Spiel starten</button>
<div class="title-instructions">
Finde den Zystinstein. Rolle ihn zum Gipfel.<br>
Halte die Kristallkeime unter Kontrolle.<br>
Werde zum Zystinstein-Guru.
</div>
<div class="title-controls">
WASD / Pfeiltasten = Bewegen &bull; Shift = Sprint &bull; Leertaste = Aktion &bull; 1-2-3 = Medikament<br>
Mobil: Swipe = Bewegen &bull; Tap = Aktion &bull; ⚡-Button = Aktion
</div>
</div>
<!-- GAME -->
<div id="screen-game" class="game-wrap" style="display:none">
<div id="tut-ov" class="tut-ov" style="display:none">
<div class="tut-bx">
<h2 id="tut-h">Level 1</h2>
<p><b>Phase 1 Suche:</b> Finde den Zystinstein unter <span id="tut-sc">5</span> Steinen. Rolle Steine zum Labor und lasse sie scannen. Achte auf leuchtende Hinweise!</p>
<p><b>Phase 2 Transport:</b> Rolle den Zystinstein zum Gipfel. Kristallkeime sprießen auf den Wegen bekämpfe sie mit Medikamenten ([Leertaste] = Sprühen). Fülle Vorräte in der Apotheke auf (2 von 3 pro Besuch).</p>
<p><b>Steuerung:</b> WASD / Pfeiltasten = Bewegen. <b>Shift</b> = Sprint (ohne Stein). Leertaste = Aktion. Tasten 1-2-3 = Medikament wählen.</p>
<p><b>Tipp:</b> Minimap oben rechts zeigt Wege, Gebäude und Keime. Der Stein-Timer pausiert, solange du in der Apotheke bist!</p>
<div class="tut-hint">Klicke irgendwo, um zu starten</div>
</div>
</div>
<div class="hud-top">
<div class="hud-l">
<span id="hud-lv" class="hud-lv">Lvl 1</span>
<span id="hud-sc" class="hud-sc">⭐ 0</span>
<span id="hud-ph" class="hud-ph">🔍 0/5</span>
</div>
<div class="hud-r">
<div id="kid-w" class="kid-w" style="display:none">
<span class="kid-i">🫘</span>
<div class="bar-t"><div id="kid-f" class="bar-f bar-ok" style="width:0%"></div></div>
</div>
<span id="st-tm" class="st-tm" style="display:none"></span>
</div>
</div>
<canvas id="gc" class="game-canvas" width="800" height="600"></canvas>
<div id="hud-bot" class="hud-bot">
<button id="med-0" class="med-b">
<div class="med-ic">💧</div><div class="med-nm">Wasser</div>
<div class="med-br"><div id="mfl-0" class="med-fl" style="width:100%;background:#4fc3f7"></div></div>
<div id="mst-0" class="med-st med-ok">100%</div>
</button>
<button id="med-1" class="med-b">
<div class="med-ic">💊</div><div class="med-nm">Bikarbonat</div>
<div class="med-br"><div id="mfl-1" class="med-fl" style="width:100%;background:#81c784"></div></div>
<div id="mst-1" class="med-st med-ok">100%</div>
</button>
<button id="med-2" class="med-b">
<div class="med-ic">💉</div><div class="med-nm">Thiola®</div>
<div class="med-br"><div id="mfl-2" class="med-fl" style="width:100%;background:#ffb74d"></div></div>
<div id="mst-2" class="med-st med-ok">100%</div>
</button>
</div>
<div id="msg-pop" class="msg-pop" style="display:none"></div>
<button id="act-btn" class="act-btn"></button>
</div>
<!-- GAME OVER -->
<div id="screen-gameover" class="screen screen--gameover" style="display:none">
<div class="go-icon">💀</div>
<h1 class="go-h">SPIEL VORBEI</h1>
<p id="go-r" class="go-reason"></p>
<div class="btn-row">
<button id="go-retry" class="btn btn--s btn--red">Level wiederholen</button>
<button id="go-rst" class="btn btn--s btn--red">Von vorne</button>
<button id="go-quit" class="btn btn--s btn--red">Beenden</button>
</div>
</div>
<!-- TRANSITION -->
<div id="screen-transition" class="screen screen--transition" style="display:none">
<div class="tr-icon">🏆</div>
<h1 class="tr-h">LEVEL GESCHAFFT!</h1>
<p id="tr-ln" class="tr-sub"></p>
<div class="tr-box">
<div class="tr-lbl">Verliehener Titel</div>
<div id="tr-t" class="tr-ttl"></div>
</div>
<button id="tr-btn" class="btn btn--s btn--grn">Nächstes Level</button>
</div>
<!-- VICTORY -->
<div id="screen-victory" class="screen screen--victory" style="display:none">
<div id="v-guru" class="v-float">🧘</div>
<div class="v-beard">🧔</div>
<h1 class="v-h">ZYSTINSTEIN-GURU</h1>
<p class="v-txt">
Sisyphus schwebt nun erleuchtet über dem Gipfel.<br>
Sein Bart weht im Wind der Erkenntnis.<br>
Die Zystinsteine sind bezwungen. Die Nieren sind sicher.
</p>
<div id="v-stone2" class="v-stone">🪨✨</div>
<button onclick="location.reload()" class="btn btn--s btn--pur" style="margin-top:32px">Zurück zum Anfang</button>
</div>
<script src="js/constants.js"></script>
<script src="js/utils.js"></script>
<script src="js/audio.js"></script>
<script src="js/world.js"></script>
<script src="js/state.js"></script>
<script src="js/screens.js"></script>
<script src="js/input.js"></script>
<script src="js/gameloop.js"></script>
<script src="js/render.js"></script>
<script src="js/main.js"></script>
</body>
</html>

12
sde/js/audio.js Normal file
View File

@@ -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)}

24
sde/js/constants.js Normal file
View File

@@ -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";

287
sde/js/gameloop.js Normal file
View File

@@ -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;ki<S.keims.length;ki++){
var k=S.keims[ki];
k.size+=S.cfg.keimGrow*(1+S.phase2Timer*0.003);
k.size=Math.min(k.size,1.0);
k.pulse=(k.pulse||0)+0.04;
}
var spawnRate=0.006+curLvl*0.003;
if(Math.random()<spawnRate){
var segs=S.net.edges;
var si=Math.floor(Math.random()*segs.length);
var ea2=S.net.nodes[segs[si][0]], eb=S.net.nodes[segs[si][1]];
var t2=rng(0.15,0.85);
var kp={x:lerp(ea2.x,eb.x,t2),y:lerp(ea2.y,eb.y,t2)};
if(dist(kp,S.player)>100&&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<S.keims.length;ki++)totK+=S.keims[ki].size;
S.kidneyDmg=Math.min(100,totK*2);
if(S.parkedStone&&!S.rollingStone){
if(!S.atPharmacy){
S.stoneAloneTimer+=dt;
}
}else{
S.stoneAloneTimer=Math.max(0,S.stoneAloneTimer-dt*0.8);
}
if(S.kidneyDmg>=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&&d<bd){best=st;bd=d;}
});
if(best){
S.rollingStone=best;best.rolling=true;
setMsg("Stein aufgehoben! Rolle ihn zum Labor \uD83D\uDD2C",2.5);
sfxPick();
}
}else{
if(dist(P,S.net.lab)<65){
var rs=S.rollingStone;
rs.scanned=true;S.stonesScanned++;
if(rs.isCystine){
setMsg("\u26A0\uFE0F ZYSTINSTEIN ERKANNT! Das Tal erwacht!",3.5,true);
sfxPos();
S.cystineStone=rs;
S.phase=2;S.score+=200;
for(var i=0;i<3+curLvl*2;i++){
var segs=S.net.edges;
var si=Math.floor(Math.random()*segs.length);
var ea2=S.net.nodes[segs[si][0]],eb=S.net.nodes[segs[si][1]];
var t2=rng(0.2,0.8);
S.keims.push({x:lerp(ea2.x,eb.x,t2),y:lerp(ea2.y,eb.y,t2),size:0.08,id:Date.now()+i,pulse:0});
}
}else{
setMsg("Negativ \u2013 kein Zystinstein. Suche weiter!",2);
sfxNeg();
rs.rolling=false;
S.rollingStone=null;
S.score+=20;
}
}else{
S.rollingStone.rolling=false;
S.rollingStone.x=P.x;S.rollingStone.y=P.y;
S.rollingStone=null;
setMsg("Stein abgelegt.",1);
}
}
}
if(S.phase===2){
if(!S.rollingStone&&S.parkedStone&&dist(P,S.parkedStone)<55){
S.rollingStone=S.parkedStone;S.parkedStone=null;
S.stoneAloneTimer=0;
setMsg("Zystinstein aufgenommen! Weiter zum Gipfel!",1.5);
sfxPick();
return;
}
if(S.rollingStone&&S.rollingStone.isCystine&&!(S.parkedStone)){
S.parkedStone=S.rollingStone;
S.parkedStone.x=P.x;S.parkedStone.y=P.y;
S.parkedStone.rolling=false;
S.rollingStone=null;
S.stoneAloneTimer=0;
setMsg("Stein geparkt. K\u00FCmmere dich um die Keime!",1.5);
return;
}
if(dist(P,S.net.apo)<65){
var mid=MEDS[S.selMed].id;
if(S.meds[mid]<S.medsMax){
S.meds[mid]=S.medsMax;
var others=MEDS.filter(function(_,i){return i!==S.selMed});
others.sort(function(a,b){return S.meds[a.id]-S.meds[b.id]});
S.meds[others[0].id]=S.medsMax;
setMsg(MEDS[S.selMed].name+" & "+others[0].name+" aufgef\u00FCllt!",1.5);
sfxRefill();S.score+=10;
return;
}
}
var mid2=MEDS[S.selMed].id;
if(S.meds[mid2]>0){
var hit=false;
for(var ki=0;ki<S.keims.length;ki++){
var k=S.keims[ki];
if(dist(P,k)<70){
var shrink=S.cfg.keimShrink*MEDS[S.selMed].pow;
k.size-=shrink;
hit=true;
for(var pi=0;pi<6;pi++){
sprayParticles.push({
x:k.x+rng(-8,8),y:k.y+rng(-8,8),
vx:rng(-40,40),vy:rng(-40,40),
life:rng(0.3,0.7),
color:MEDS[S.selMed].color
});
}
}
}
if(hit){
S.meds[mid2]=Math.max(0,S.meds[mid2]-2.5);
sfxSpray();
}
S.keims=S.keims.filter(function(k){return k.size>0.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";
}

17
sde/js/input.js Normal file
View File

@@ -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")});

3
sde/js/main.js Normal file
View File

@@ -0,0 +1,3 @@
"use strict";
show("title");

250
sde/js/render.js Normal file
View File

@@ -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;ki<S.keims.length;ki++){
var k=S.keims[ki];
var sz=k.size*30;
var pulse=Math.sin(k.pulse)*0.12+1;
var r2=sz*pulse;
var auraA=0.08+k.size*0.25;
ctx.fillStyle="rgba(220,40,40,"+auraA+")";
ctx.beginPath();ctx.arc(k.x,k.y,r2*2.5,0,Math.PI*2);ctx.fill();
ctx.fillStyle="rgba(220,200,50,"+(0.5+k.size*0.4)+")";
drawCrystal(ctx,k.x,k.y,r2);
ctx.fillStyle="rgba(255,240,100,"+(0.3+k.size*0.3)+")";
drawCrystal(ctx,k.x,k.y,r2*0.4);
if(k.size>0.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;ki<S.keims.length;ki++){
var k=S.keims[ki];
if(dist(P,k)<70&&S.meds[MEDS[S.selMed].id]>0){
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<spikes;i++){
var a=(i/spikes)*Math.PI*2-Math.PI/2;
var outerR=r*(0.6+(i%2)*0.4);
var px=x+Math.cos(a)*outerR,py=y+Math.sin(a)*outerR;
if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
var a2=((i+0.5)/spikes)*Math.PI*2-Math.PI/2;
ctx.lineTo(x+Math.cos(a2)*r*0.3,y+Math.sin(a2)*r*0.3);
}
ctx.closePath();ctx.fill();
}
function drawBldg(ctx,x,y,emoji,label,col){
ctx.fillStyle="#00000025";ctx.beginPath();ctx.ellipse(x,y+28,32,8,0,0,Math.PI*2);ctx.fill();
ctx.fillStyle=col;ctx.fillRect(x-28,y-22,56,44);
ctx.fillStyle=col+"cc";ctx.beginPath();ctx.moveTo(x-32,y-22);ctx.lineTo(x,y-38);ctx.lineTo(x+32,y-22);ctx.closePath();ctx.fill();
ctx.fillStyle="#1a1a1a";ctx.fillRect(x-6,y+6,12,16);
ctx.font="22px sans-serif";ctx.textAlign="center";ctx.fillText(emoji,x,y+2);
ctx.fillStyle="#ffffffbb";ctx.font="bold 10px sans-serif";ctx.fillText(label,x,y+40);
}
function drawMinimap(ctx,cam){
var mmW=130,mmH=100,mmX=cam.x+CW-mmW-10,mmY=cam.y+10;
var sx=mmW/S.net.mapW,sy=mmH/S.net.mapH;
ctx.fillStyle="rgba(0,0,0,0.55)";ctx.fillRect(mmX-2,mmY-2,mmW+4,mmH+4);
ctx.fillStyle="rgba(40,60,30,0.8)";ctx.fillRect(mmX,mmY,mmW,mmH);
ctx.strokeStyle="#9a8a6a50";ctx.lineWidth=1.5;
S.net.edges.forEach(function(e){
var a=S.net.nodes[e[0]],b=S.net.nodes[e[1]];
ctx.beginPath();ctx.moveTo(mmX+a.x*sx,mmY+a.y*sy);ctx.lineTo(mmX+b.x*sx,mmY+b.y*sy);ctx.stroke();
});
ctx.fillStyle="#4488ff";ctx.fillRect(mmX+S.net.lab.x*sx-2,mmY+S.net.lab.y*sy-2,5,5);
ctx.fillStyle="#44dd44";ctx.fillRect(mmX+S.net.apo.x*sx-2,mmY+S.net.apo.y*sy-2,5,5);
ctx.fillStyle="#ffffff";ctx.fillRect(mmX+S.net.peak.x*sx-2,mmY+S.net.peak.y*sy-2,5,5);
S.keims.forEach(function(k){
ctx.fillStyle="rgba(220,200,50,"+(0.4+k.size*0.5)+")";
ctx.beginPath();ctx.arc(mmX+k.x*sx,mmY+k.y*sy,1.5+k.size*2,0,Math.PI*2);ctx.fill();
});
if(S.parkedStone){ctx.fillStyle="#ffcc00";ctx.fillRect(mmX+S.parkedStone.x*sx-2,mmY+S.parkedStone.y*sy-2,4,4);}
ctx.fillStyle="#ff4444";ctx.beginPath();ctx.arc(mmX+S.player.x*sx,mmY+S.player.y*sy,3,0,Math.PI*2);ctx.fill();
ctx.strokeStyle="#ffffff35";ctx.lineWidth=1;ctx.strokeRect(mmX+cam.x*sx,mmY+cam.y*sy,CW*sx,CH*sy);
ctx.fillStyle="#ffffff55";ctx.font="7px sans-serif";ctx.textAlign="left";
ctx.fillText("Lab",mmX+S.net.lab.x*sx+5,mmY+S.net.lab.y*sy+3);
ctx.fillText("Apo",mmX+S.net.apo.x*sx+5,mmY+S.net.apo.y*sy+3);
ctx.fillText("Gipfel",mmX+S.net.peak.x*sx+5,mmY+S.net.peak.y*sy+3);
}

58
sde/js/screens.js Normal file
View File

@@ -0,0 +1,58 @@
"use strict";
function hideAll(){
["screen-title","screen-game","screen-gameover","screen-transition","screen-victory"].forEach(function(id){$(id).style.display="none"});
if(animId){cancelAnimationFrame(animId);animId=null;}
}
function show(n){
hideAll();curScreen=n;$("screen-"+n).style.display="flex";
if(n==="title")runTitle();
if(n==="victory")runVic();
if(n==="game")startGame();
}
function runTitle(){
titleHue=0;
(function t(){if(curScreen!=="title")return;titleHue=(titleHue+1)%360;
var e=$("title-glow");if(e)e.style.background="radial-gradient(circle at 50% 30%,hsla("+titleHue+",40%,30%,0.15) 0%,transparent 60%)";
animId=requestAnimationFrame(t)})();
}
function runVic(){
vicF=0;
(function t(){if(curScreen!=="victory")return;vicF+=0.05;
var y=Math.sin(vicF)*10;
var g=$("v-guru"),s=$("v-stone2");
if(g)g.style.transform="translateY("+y+"px)";
if(s)s.style.transform="translateY("+Math.sin(vicF+1)*6+"px)";
animId=requestAnimationFrame(t)})();
}
function showTrans(l){
var c=LEVELS[l];
$("tr-ln").textContent="\u201E"+c.name+"\u201C bezwungen";
$("tr-t").textContent=c.title;
$("tr-btn").textContent=l<2?"N\u00E4chstes Level":"Zum Finale";
$("tr-btn").onclick=function(){if(l>=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();
}

31
sde/js/state.js Normal file
View File

@@ -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,
};
}

13
sde/js/utils.js Normal file
View File

@@ -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))+")";
}

191
sde/js/world.js Normal file
View File

@@ -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&&r<rows&&c>0&&c<cols)?rng(-12,12):0;
var jy=(r>0&&r<rows&&c>0&&c<cols)?rng(-8,8):0;
var nd={x:offX+c*gapX+jx, y:offY+r*gapY+jy, id:nid, r:r, c:c, role:"path"};
nodes.push(nd);
grid[r][c]=nd;
nid++;
}
}
var edges=[];
var edgeSet={};
function addEdge(a,b){
if(!a||!b)return;
var k=Math.min(a.id,b.id)+"-"+Math.max(a.id,b.id);
if(edgeSet[k])return;
edgeSet[k]=true;
edges.push([a.id,b.id]);
}
for(var r=0;r<=rows;r++){
for(var c=0;c<=cols;c++){
if(!grid[r][c])continue;
if(c<cols&&grid[r][c+1]) addEdge(grid[r][c],grid[r][c+1]);
if(r<rows&&grid[r+1]&&grid[r+1][c]) addEdge(grid[r][c],grid[r+1][c]);
}
}
for(var d=0;d<2+li;d++){
var src=nodes[Math.floor(Math.random()*nodes.length)];
var dir=Math.floor(Math.random()*4);
var dx=[gapX,0,-gapX,0][dir], dy=[0,gapY,0,-gapY][dir];
var nx=src.x+dx+rng(-8,8), ny=src.y+dy+rng(-8,8);
if(nx>40&&nx<mapW-40&&ny>40&&ny<mapH-40){
var dn={x:nx,y:ny,id:nid,role:"deadend"};
nodes.push(dn); nid++;
edges.push([src.id,dn.id]);
}
}
var startC=Math.floor(cols/2);
var startN=grid[rows][startC];
if(!startN){
for(var cc=0;cc<=cols;cc++){
if(grid[rows][cc]){startN=grid[rows][cc];break;}
}
}
var visited={};
var queue=[startN.id];
visited[startN.id]=true;
var adj={};
edges.forEach(function(e){
if(!adj[e[0]])adj[e[0]]=[];
if(!adj[e[1]])adj[e[1]]=[];
adj[e[0]].push(e[1]);
adj[e[1]].push(e[0]);
});
while(queue.length){
var cur=queue.shift();
(adj[cur]||[]).forEach(function(nb){
if(!visited[nb]){visited[nb]=true;queue.push(nb);}
});
}
nodes.forEach(function(n){
if(visited[n.id])return;
var best=null,bd=Infinity;
nodes.forEach(function(m){
if(!visited[m.id])return;
var d=dist(n,m);
if(d<bd){bd=d;best=m;}
});
if(best){
edges.push([n.id,best.id]);
if(!adj[n.id])adj[n.id]=[];
if(!adj[best.id])adj[best.id]=[];
adj[n.id].push(best.id);
adj[best.id].push(n.id);
visited[n.id]=true;
}
});
var peakC=Math.floor(cols/2);
var peakN=grid[0][peakC]||nodes[0];
var labN=grid[rows-1]?grid[rows-1][0]||grid[rows][0]||nodes[1]:nodes[1];
if(labN.id===startN.id){
for(var ci=0;ci<=cols;ci++){
if(grid[rows]&&grid[rows][ci]&&grid[rows][ci].id!==startN.id){labN=grid[rows][ci];break;}
}
}
var apoRow=Math.floor(rows*0.4);
var apoN=grid[apoRow]?grid[apoRow][cols]||grid[apoRow][cols-1]||nodes[3]:nodes[3];
peakN.role="peak"; startN.role="start"; labN.role="lab"; apoN.role="apo";
return {nodes:nodes,edges:edges,mapW:mapW,mapH:mapH,
peak:peakN,start:startN,lab:labN,apo:apoN};
}
// ─── Vegetation ─────────
function genVeg(net){
var trees=[],bushes=[];
var segs=net.edges.map(function(e){return{a:net.nodes[e[0]],b:net.nodes[e[1]]};});
function nearPath(x,y,minD){
for(var i=0;i<segs.length;i++){
var s=segs[i],ax=s.a.x,ay=s.a.y,bx=s.b.x,by=s.b.y;
var dx=bx-ax,dy=by-ay,l2=dx*dx+dy*dy;
var t=l2===0?0:clamp(((x-ax)*dx+(y-ay)*dy)/l2,0,1);
var d=Math.sqrt((x-(ax+t*dx))**2+(y-(ay+t*dy))**2);
if(d<minD)return true;
}
return false;
}
for(var i=0;i<500;i++){
var vx=rng(15,net.mapW-15), vy=rng(15,net.mapH-15);
if(nearPath(vx,vy,45))continue;
if(Math.random()<0.5){
trees.push({x:vx,y:vy,sz:rng(14,30),shade:Math.floor(rng(0,4)),trunk:rng(10,18)});
}else{
bushes.push({x:vx,y:vy,sz:rng(6,16),shade:Math.floor(rng(0,3))});
}
}
return{trees:trees,bushes:bushes};
}
// ─── Stones on paths ────
function genStones(net,count,li){
var stones=[];
var cIdx=Math.floor(Math.random()*count);
var segs=net.edges.map(function(e){return{a:net.nodes[e[0]],b:net.nodes[e[1]]};});
for(var i=0;i<count;i++){
var pt,tries=0;
do{
var s=segs[Math.floor(Math.random()*segs.length)];
var t=rng(0.2,0.8);
pt={x:lerp(s.a.x,s.b.x,t),y:lerp(s.a.y,s.b.y,t)};
tries++;
}while(tries<100&&(
dist(pt,net.lab)<70||dist(pt,net.start)<60||dist(pt,net.peak)<70||dist(pt,net.apo)<70||
stones.some(function(s2){return dist(pt,s2)<55;})
));
var isC=i===cIdx;
stones.push({id:i,x:pt.x,y:pt.y,isCystine:isC,
color:isC?CYSTINE_COL:STONE_COLS[i%STONE_COLS.length],
scanned:false,rolling:false,shape:Math.floor(Math.random()*3),
rollAngle:0});
}
var cs=stones[cIdx];
var hints=[];
for(var h=0;h<3+li;h++){
hints.push({x:cs.x+rng(-65,65),y:cs.y+rng(-65,65),phase:rng(0,Math.PI*2)});
}
return{stones:stones,hints:hints};
}
// ─── Closest point on path network ────
function closestOnPaths(px,py,edges,nodes){
var best=Infinity,bx=px,by=py;
for(var i=0;i<edges.length;i++){
var a=nodes[edges[i][0]],b=nodes[edges[i][1]];
var dx=b.x-a.x,dy=b.y-a.y,l2=dx*dx+dy*dy;
var t=l2===0?0:clamp(((px-a.x)*dx+(py-a.y)*dy)/l2,0,1);
var cx=a.x+t*dx,cy=a.y+t*dy;
var d=(px-cx)**2+(py-cy)**2;
if(d<best){best=d;bx=cx;by=cy;}
}
return{x:bx,y:by,d:Math.sqrt(best)};
}

69
sde/style.css Normal file
View File

@@ -0,0 +1,69 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:#0a0a0a;color:#ddd;font-family:'Segoe UI',Tahoma,sans-serif;overflow-x:hidden;touch-action:none;user-select:none;-webkit-user-select:none}
.screen{width:100%;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden}
.screen--title{background:radial-gradient(ellipse at 50% 80%,#2a1f1a 0%,#0d0b09 100%)}
.screen--gameover{background:radial-gradient(ellipse at 50% 50%,#2a0a0a 0%,#0a0505 100%)}
.screen--transition{background:radial-gradient(ellipse at 50% 50%,#1a2a1a 0%,#080d08 100%)}
.screen--victory{background:radial-gradient(ellipse at 50% 30%,#2a2040 0%,#0a0810 100%)}
.title-glow{position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none}
.title-icon{font-size:72px;margin-bottom:8px}
.title-heading{font-size:42px;font-weight:300;letter-spacing:6px;text-transform:uppercase;text-shadow:0 0 30px rgba(200,180,140,0.3);color:#e8dcc8;margin:0 0 8px;text-align:center}
.title-sub{font-size:16px;opacity:0.6;letter-spacing:2px;color:#e8dcc8;margin:0 0 40px}
.title-instructions{margin-top:40px;font-size:13px;opacity:0.4;text-align:center;max-width:440px;line-height:1.6;color:#e8dcc8}
.title-controls{position:absolute;bottom:16px;font-size:11px;opacity:0.25;color:#e8dcc8;text-align:center;max-width:90%}
.btn{padding:14px 48px;font-size:18px;font-weight:600;letter-spacing:3px;border:1px solid rgba(160,144,96,0.38);border-radius:4px;cursor:pointer;background:linear-gradient(135deg,#3a2f25,#2a2018);color:#d4c4a8;text-transform:uppercase;transition:all 0.3s;font-family:inherit}
.btn:hover{background:linear-gradient(135deg,#5a4f35,#3a3028);border-color:rgba(192,160,96,0.56)}
.btn--s{padding:10px 28px;font-size:14px;letter-spacing:1px}
.btn--red{background:#1a0808;color:#d4a8a8;border-color:rgba(128,64,64,0.38)}
.btn--grn{background:#0a1a0a;color:#a0d0a0;border-color:rgba(64,128,64,0.38)}
.btn--pur{background:rgba(21,16,32,0.5);color:#c0b8d8;border-color:rgba(96,80,128,0.38)}
.btn-row{display:flex;gap:12px;flex-wrap:wrap;justify-content:center}
.tr-icon{font-size:64px;margin-bottom:16px}
.tr-h{font-size:32px;font-weight:300;letter-spacing:4px;margin:0 0 8px}
.tr-sub{font-size:18px;opacity:0.9;margin:8px 0}
.tr-box{margin:20px 0;padding:16px 40px;border:1px solid rgba(64,128,64,0.38);border-radius:8px;background:rgba(10,26,10,0.5);text-align:center}
.tr-lbl{font-size:13px;opacity:0.6;letter-spacing:2px;text-transform:uppercase}
.tr-ttl{font-size:24px;font-weight:600;margin-top:6px;color:#a0e0a0}
.v-float{font-size:80px;text-shadow:0 0 40px rgba(180,160,255,0.4)}
.v-beard{font-size:40px;margin-top:-10px}
.v-h{font-size:36px;font-weight:300;letter-spacing:6px;margin:16px 0 8px;text-shadow:0 0 20px rgba(180,160,255,0.3);color:#e0d8f0}
.v-txt{font-size:16px;opacity:0.7;max-width:420px;text-align:center;line-height:1.7;margin:8px 0 32px;color:#e0d8f0}
.v-stone{font-size:48px}
.go-icon{font-size:64px;margin-bottom:16px}
.go-h{font-size:36px;font-weight:300;letter-spacing:4px;margin:0 0 12px;color:#e8c8c8}
.go-reason{font-size:15px;opacity:0.7;margin:0 0 32px;text-align:center;max-width:400px;color:#e8c8c8}
.game-wrap{width:100%;min-height:100vh;background:#0a0a0a;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative}
.hud-top{width:100%;max-width:800px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:8px 12px;background:#111;border-bottom:1px solid #222;font-size:12px;gap:8px}
.hud-l,.hud-r{display:flex;gap:12px;align-items:center}
.hud-lv{color:rgba(160,144,96,0.5);letter-spacing:1px;font-size:10px;text-transform:uppercase}
.hud-sc{color:#a0a080}
.hud-ph{color:#80a0c0}
.kid-w{display:flex;align-items:center;gap:4px}
.kid-i{font-size:14px}
.bar-t{width:50px;height:8px;background:#222;border-radius:4px;overflow:hidden}
.bar-f{height:100%;transition:width 0.3s;border-radius:4px}
.bar-ok{background:#4caf50}.bar-wn{background:#ff9800}.bar-dn{background:#e53935}
.st-tm{font-size:11px;font-weight:600}
.st-w{color:#ffaa00}.st-d{color:#ff4444}
.game-canvas{width:100%;max-width:800px;border:1px solid #222;cursor:crosshair;display:block}
.hud-bot{width:100%;max-width:800px;display:none;flex-wrap:wrap;align-items:center;justify-content:center;padding:8px 12px;background:#111;border-top:1px solid #222;gap:8px}
.hud-bot.vis{display:flex}
.med-b{display:flex;flex-direction:column;align-items:center;padding:6px 14px;border:2px solid #333;border-radius:8px;background:#0a0a0a;cursor:pointer;min-width:80px;transition:all 0.2s;font-family:inherit;color:#ddd}
.med-b.sel{background:#1a1a2a}
.med-ic{font-size:18px}
.med-nm{font-size:10px;color:#999;margin-top:2px}
.med-br{width:50px;height:6px;background:#222;border-radius:3px;overflow:hidden;margin-top:4px}
.med-fl{height:100%;border-radius:3px;transition:width 0.3s}
.med-st{font-size:9px;margin-top:2px}
.med-lo{color:#e53935}.med-ok{color:#666}
.msg-pop{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.88);padding:12px 24px;border-radius:8px;border:1px solid #444;font-size:15px;font-weight:600;color:#ddd;pointer-events:none;text-align:center;max-width:360px;animation:fi 0.2s ease;z-index:20}
.msg-al{color:#ffcc00}
.act-btn{position:fixed;bottom:20px;right:20px;z-index:50;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#4a3f30,#2a2018);border:2px solid #6a5a40;color:#d4c4a8;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,0.5)}
.tut-ov{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);z-index:100;display:flex;align-items:center;justify-content:center}
.tut-bx{background:#1a1a1a;border-radius:12px;padding:28px 32px;max-width:460px;border:1px solid #333;line-height:1.7;color:#aaa;font-size:13px}
.tut-bx h2{margin:0 0 12px;font-size:20px;color:#e8dcc8}
.tut-bx b{color:#ccc}
.tut-bx p{margin:8px 0}
.tut-hint{text-align:center;margin-top:16px;font-size:13px;color:#888}
@keyframes fi{from{opacity:0;transform:translate(-50%,-45%)}to{opacity:1;transform:translate(-50%,-50%)}}
@media(max-width:500px){.title-heading{font-size:26px;letter-spacing:3px}.title-icon{font-size:50px}.btn{padding:12px 28px;font-size:14px}.tut-bx{margin:12px;padding:18px}.hud-top{font-size:11px}.med-b{min-width:62px;padding:4px 6px}}