Added kidney_labe and Cyste_kid
This commit is contained in:
72
Cyst_Kid/index.html
Normal file
72
Cyst_Kid/index.html
Normal 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
115
Cyst_Kid/js/constants.js
Normal 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
266
Cyst_Kid/js/game.js
Normal 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
213
Cyst_Kid/js/movement.js
Normal 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
92
Cyst_Kid/js/render.js
Normal 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);
|
||||
}
|
||||
|
||||
});
|
||||
11
Cyst_Kid/js/screen-game-over.js
Normal file
11
Cyst_Kid/js/screen-game-over.js
Normal 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';
|
||||
}
|
||||
|
||||
});
|
||||
11
Cyst_Kid/js/screen-level-complete.js
Normal file
11
Cyst_Kid/js/screen-level-complete.js
Normal 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';
|
||||
}
|
||||
|
||||
});
|
||||
15
Cyst_Kid/js/screen-start.js
Normal file
15
Cyst_Kid/js/screen-start.js
Normal 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
28
Cyst_Kid/js/screen-win.js
Normal 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
12
Cyst_Kid/js/sound.js
Normal 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))}
|
||||
}
|
||||
872
Cyst_Kid/level-designer.html
Normal file
872
Cyst_Kid/level-designer.html
Normal 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>1–8</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
264
Cyst_Kid/style.css
Normal 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
4
WEB-INF/jboss-web.xml
Normal 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
4
WEB-INF/web.xml
Normal 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
21
index.html
Normal 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 & 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
67
kidney_lab/CLAUDE.md
Normal 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
637
kidney_lab/css/styles.css
Normal 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); }
|
||||
40
kidney_lab/description.txt
Normal file
40
kidney_lab/description.txt
Normal 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
50
kidney_lab/index.html
Normal 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 & 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
200
kidney_lab/js/conveyor.js
Normal 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
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
144
kidney_lab/js/highscore.js
Normal 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 & 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
140
kidney_lab/js/intro.js
Normal 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 & 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 0</em> (left) to collect · at <em>column 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
84
kidney_lab/js/lab.js
Normal 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 0–1 of the current analysis (0 if idle). */
|
||||
analyzeProgress() {
|
||||
if (this.state !== LabState.ANALYZING) return 0;
|
||||
return 1 - this.analyzeTimer / SETTINGS.LAB_ANALYZE_TIME;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (this.flashTimer > 0) this.flashTimer -= dt;
|
||||
|
||||
if (this.state === LabState.ANALYZING) {
|
||||
this.analyzeTimer -= dt;
|
||||
if (this.analyzeTimer <= 0) {
|
||||
this.analyzeTimer = 0;
|
||||
this.state = LabState.IDLE;
|
||||
if (this._onMedReady) {
|
||||
this._onMedReady();
|
||||
this._onMedReady = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
kidney_lab/js/player.js
Normal file
140
kidney_lab/js/player.js
Normal 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
101
kidney_lab/js/settings.js
Normal 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
24
kidney_lab/readme.txt
Normal 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
970
sde/game.js
Normal 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
130
sde/index.html
Normal 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 • Shift = Sprint • Leertaste = Aktion • 1-2-3 = Medikament<br>
|
||||
Mobil: Swipe = Bewegen • Tap = Aktion • ⚡-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
12
sde/js/audio.js
Normal 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
24
sde/js/constants.js
Normal 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
287
sde/js/gameloop.js
Normal 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
17
sde/js/input.js
Normal 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
3
sde/js/main.js
Normal file
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
|
||||
show("title");
|
||||
250
sde/js/render.js
Normal file
250
sde/js/render.js
Normal 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
58
sde/js/screens.js
Normal 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
31
sde/js/state.js
Normal 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
13
sde/js/utils.js
Normal 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
191
sde/js/world.js
Normal 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
69
sde/style.css
Normal 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}}
|
||||
Reference in New Issue
Block a user