Compare commits
10 Commits
aa66c030f8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82befd2be7 | ||
|
|
deb4ddeaab | ||
|
|
5c989c5e70 | ||
|
|
04c91983e7 | ||
|
|
848fdc7016 | ||
|
|
1e7b21a935 | ||
|
|
510f12725e | ||
|
|
0e90d2e535 | ||
|
|
feed1c9c40 | ||
|
|
9cc8ac8cad |
76
Cyst_Kid/index.html
Normal file
76
Cyst_Kid/index.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!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">Sammle Punkte, weiche den Schmerzen aus<br>und bringe die Medikamente rechtzeitig<br>zum Patienten!</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.</p>
|
||||||
|
<p id="go-score-text" class="end-score-text"></p>
|
||||||
|
<input type="text" id="go-playerName" placeholder="Dein Name" maxlength="20" class="name-input">
|
||||||
|
<button id="go-submitScoreBtn" class="arcade-btn">EINTRAGEN</button>
|
||||||
|
<div id="go-ranking-table-container" class="ranking-table-container"></div>
|
||||||
|
<button id="restartBtn" class="arcade-btn" style="display:none">ZURÜCK ZUM START</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.</p>
|
||||||
|
<p id="win-score-text" class="end-score-text"></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" class="ranking-table-container"></div>
|
||||||
|
<button id="playAgainBtn" class="arcade-btn" style="display:none">ZURÜCK ZUM START</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>
|
||||||
99
Cyst_Kid/js/constants.js
Normal file
99
Cyst_Kid/js/constants.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/* ========================================
|
||||||
|
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
|
||||||
|
[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],//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
|
||||||
|
[1,1,1,1,1,0,1,2,1,1,1,1,1,1,1,1,1,1,1,1,2,1,0,1,1,1,1,1],//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 MED_SPAWNS = [{r:13,c:14}];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// ---- Challenge timing ----
|
||||||
|
const CHALL_FIRST_MIN = 7; // seconds before first challenge (min)
|
||||||
|
const CHALL_FIRST_MAX = 13; // seconds before first challenge (max)
|
||||||
|
const CHALL_NEXT_MIN = 8; // seconds between challenges (min)
|
||||||
|
const CHALL_NEXT_MAX = 15; // seconds between challenges (max)
|
||||||
|
const CHALL_DURATION = 10; // seconds player has to deliver the med
|
||||||
|
|
||||||
|
// ---- Player / Ghost ----
|
||||||
|
const LIVES = 3; // starting lives
|
||||||
|
const WIN_LEVEL = 3; // level at which the player wins
|
||||||
|
const SPEED_MULT = 2; // sprint / super-mode speed multiplier
|
||||||
|
const COLL_DIST = 0.8; // collision detection radius (in tiles)
|
||||||
|
const GHOST_SPD_BASE = 1.2; // ghost base speed (tiles/frame)
|
||||||
|
const GHOST_SPD_LEVEL = 0.2; // ghost speed increase per level
|
||||||
|
const GHOST_SCARED_MULT = 0.55; // ghost speed while scared
|
||||||
|
const GHOST_REL_INTERVAL = 80; // frames between ghost releases
|
||||||
|
const GHOST_REL_OFFSET = 50; // frame offset for first ghost release
|
||||||
|
|
||||||
|
// ---- Scoring ----
|
||||||
|
const SC_DOT = 1; // points per dot
|
||||||
|
const SC_GHOST = 5; // points per scared ghost eaten
|
||||||
|
const SC_CHALL = 25; // points for completing a challenge
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
323
Cyst_Kid/js/game.js
Normal file
323
Cyst_Kid/js/game.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/* ========================================
|
||||||
|
CYST-KID — Game Core
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
class Game{
|
||||||
|
constructor(){
|
||||||
|
this.cv=document.getElementById('gameCanvas');
|
||||||
|
this.cx=this.cv.getContext('2d');
|
||||||
|
this.snd=new Snd();
|
||||||
|
this.cv.width=CW;this.cv.height=CH;
|
||||||
|
this.state='start';
|
||||||
|
this.lv=1;this.lives=3;this.sc=0;this.lsc=0;
|
||||||
|
this.lt=0;this.ld=0;this.tt=0;this.td=0;
|
||||||
|
this.dots=[];this.nDots=0;this.eDots=0;
|
||||||
|
this.pl=null;this.pains=[];
|
||||||
|
this.fx=[];this.fr=0;this.lastT=0;this.acc=0;
|
||||||
|
// Input
|
||||||
|
this.ks={up:false,down:false,left:false,right:false,shift:false};
|
||||||
|
this.swD=null;
|
||||||
|
this.rank=JSON.parse(localStorage.getItem('ckr3')||'[]');
|
||||||
|
this._inp();this._ui();this._rsz();
|
||||||
|
window.addEventListener('resize',()=>this._rsz());
|
||||||
|
this._rf=this._loop.bind(this);requestAnimationFrame(this._rf);
|
||||||
|
}
|
||||||
|
|
||||||
|
_rsz(){
|
||||||
|
const s=Math.min(window.innerWidth*.95/CW,window.innerHeight*.95/CH,2.5);
|
||||||
|
this.cv.style.width=CW*s+'px';this.cv.style.height=CH*s+'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- INPUT ----------
|
||||||
|
_inp(){
|
||||||
|
window.addEventListener('keydown',e=>{
|
||||||
|
if(e.target.tagName==='INPUT')return;
|
||||||
|
let h=true;
|
||||||
|
switch(e.code){
|
||||||
|
case'ArrowUp':case'KeyW':this.ks.up=true;break;
|
||||||
|
case'ArrowDown':case'KeyS':this.ks.down=true;break;
|
||||||
|
case'ArrowLeft':case'KeyA':this.ks.left=true;break;
|
||||||
|
case'ArrowRight':case'KeyD':this.ks.right=true;break;
|
||||||
|
case'ShiftLeft':case'ShiftRight':this.ks.shift=true;break;
|
||||||
|
case'Escape':this._esc();break;
|
||||||
|
case'Enter':case'NumpadEnter':this._ent();break;
|
||||||
|
default:h=false;
|
||||||
|
}
|
||||||
|
if(h)e.preventDefault();
|
||||||
|
});
|
||||||
|
window.addEventListener('keyup',e=>{
|
||||||
|
switch(e.code){
|
||||||
|
case'ArrowUp':case'KeyW':this.ks.up=false;break;
|
||||||
|
case'ArrowDown':case'KeyS':this.ks.down=false;break;
|
||||||
|
case'ArrowLeft':case'KeyA':this.ks.left=false;break;
|
||||||
|
case'ArrowRight':case'KeyD':this.ks.right=false;break;
|
||||||
|
case'ShiftLeft':case'ShiftRight':this.ks.shift=false;break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let tx=0,ty=0;
|
||||||
|
this.cv.addEventListener('touchstart',e=>{e.preventDefault();tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:false});
|
||||||
|
this.cv.addEventListener('touchmove',e=>{e.preventDefault();const dx=e.touches[0].clientX-tx,dy=e.touches[0].clientY-ty;if(Math.abs(dx)+Math.abs(dy)<12)return;this.swD=Math.abs(dx)>Math.abs(dy)?(dx>0?'right':'left'):(dy>0?'down':'up');tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:false});
|
||||||
|
this._gpi=null;
|
||||||
|
window.addEventListener('gamepadconnected',e=>{this._gpi=e.gamepad.index});
|
||||||
|
window.addEventListener('gamepaddisconnected',e=>{if(this._gpi===e.gamepad.index)this._gpi=null});
|
||||||
|
|
||||||
|
// Virtual joystick — global touch tracking so dragging outside the circle keeps working
|
||||||
|
const base=document.getElementById('joystick-base');
|
||||||
|
const knob=document.getElementById('joystick-knob');
|
||||||
|
if(base&&knob){
|
||||||
|
const MAX=39; // max knob travel px
|
||||||
|
const DEAD=10; // deadzone px
|
||||||
|
|
||||||
|
let joyId=null, cx=0, cy=0; // active touch id and fixed center coords
|
||||||
|
|
||||||
|
const moveKnob=(dx,dy)=>{
|
||||||
|
const dist=Math.hypot(dx,dy);
|
||||||
|
const scale=Math.min(dist,MAX)/(dist||1);
|
||||||
|
knob.style.transform=`translate(calc(-50% + ${dx*scale}px), calc(-50% + ${dy*scale}px))`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDir=(dx,dy)=>{
|
||||||
|
this.ks.up=this.ks.down=this.ks.left=this.ks.right=false;
|
||||||
|
if(Math.hypot(dx,dy)<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._ov('start')};
|
||||||
|
document.getElementById('submitScoreBtn').onclick=()=>this._sub();
|
||||||
|
document.getElementById('playAgainBtn').onclick=()=>{this.sc=0;this._ov('start')};
|
||||||
|
document.getElementById('go-submitScoreBtn').onclick=()=>this._subGO();
|
||||||
|
document.getElementById('playerName').addEventListener('keydown',e=>{if(e.key==='Enter'){e.stopPropagation();this._sub()}});
|
||||||
|
document.getElementById('go-playerName').addEventListener('keydown',e=>{if(e.key==='Enter'){e.stopPropagation();this._subGO()}});
|
||||||
|
}
|
||||||
|
|
||||||
|
_esc(){if(this.state==='playing'){this.sc=0;this.lsc=0;this.state='start';this._ov('start')}}
|
||||||
|
|
||||||
|
_ent(){
|
||||||
|
if(this.state==='lvlDone'){this.lv++;this._init();this.state='playing';this._ov(null)}
|
||||||
|
else if(this.state==='over'||this.state==='win'){this.sc=0;this._ov('start')}
|
||||||
|
else if(this.state==='start'){this.snd.init();this._start()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- OVERLAY / SCREEN MANAGER ----------
|
||||||
|
_ov(n){
|
||||||
|
['start-screen','level-complete-screen','game-over-screen','win-screen'].forEach(id=>document.getElementById(id).style.display='none');
|
||||||
|
if(n==='start') this._showStart();
|
||||||
|
if(n==='lvlDone') this._showLvlDone();
|
||||||
|
if(n==='over') this._showGameOver();
|
||||||
|
if(n==='win') this._showWin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- LIFECYCLE ----------
|
||||||
|
_init(){
|
||||||
|
this.dots=[];this.nDots=0;this.eDots=0;
|
||||||
|
this.meds=MED_SPAWNS.map(sp=>({r:sp.r,c:sp.c,eaten:true,pulse:0,respT:0}));
|
||||||
|
for(let r=0;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.fx=[];this.lives=LIVES;this.qDir=null;
|
||||||
|
this.hasMed=false;this.patient=null;
|
||||||
|
this.challActive=false;this.challTimer=0;this.challGhostIdx=-1;
|
||||||
|
this.nextChallIn=(FPS*(CHALL_FIRST_MIN+Math.random()*(CHALL_FIRST_MAX-CHALL_FIRST_MIN)))|0;
|
||||||
|
|
||||||
|
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:GHOST_SPD_BASE+this.lv*GHOST_SPD_LEVEL,
|
||||||
|
strat:st[i], scared:false, eaten:i>=2,
|
||||||
|
inHouse:true, leaving:false,
|
||||||
|
relT:i<2?i*GHOST_REL_INTERVAL+GHOST_REL_OFFSET:999999,
|
||||||
|
dormant:i>=2,
|
||||||
|
pulse:Math.random()*6.28
|
||||||
|
}));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -------- MAIN LOOP --------
|
||||||
|
_loop(ts){
|
||||||
|
if(!this.lastT)this.lastT=ts;
|
||||||
|
const dt=ts-this.lastT;this.lastT=ts;
|
||||||
|
if(this.state==='playing'){this.acc+=dt;let s=0;while(this.acc>=TICK&&s<5){this._upd();this.acc-=TICK;s++}if(this.acc>TICK*5)this.acc=0}
|
||||||
|
this._draw();requestAnimationFrame(this._rf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- UPDATE --------
|
||||||
|
_upd(){
|
||||||
|
this.fr++;this.lt++;
|
||||||
|
const liveDir=this._dir();
|
||||||
|
if(liveDir) this.qDir=liveDir;
|
||||||
|
const wantDir=this.qDir;
|
||||||
|
const base=BASE_SPD+(this.lv-1)*GHOST_SPD_LEVEL;
|
||||||
|
const gp=this._gp();
|
||||||
|
const sprint=this.ks.shift||(gp.sprint||false);
|
||||||
|
this.pl.spd=this.sup?base*SPEED_MULT:(sprint?base*SPEED_MULT:base);
|
||||||
|
|
||||||
|
this._movePl(wantDir);
|
||||||
|
this._movePains();
|
||||||
|
this._coll();
|
||||||
|
this._tickMeds();
|
||||||
|
this._tickChall();
|
||||||
|
this.fx=this.fx.filter(f=>{f.t--;return f.t>0});
|
||||||
|
this._chkDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- COLLISIONS --------
|
||||||
|
_coll(){
|
||||||
|
for(const p of this.pains){
|
||||||
|
if(p.eaten||p.inHouse||p.leaving)continue;
|
||||||
|
if(Math.abs(this.pl.x-p.x)+Math.abs(this.pl.y-p.y)<TILE*COLL_DIST){
|
||||||
|
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.sc+=this.lsc;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];
|
||||||
|
if(p.dormant){p.inHouse=true;p.leaving=false;p.eaten=true;p.relT=999999;p.moving=false;return}
|
||||||
|
p.x=h.c*TILE;p.y=h.r*TILE+HUD_TOP;p.gc=h.c;p.gr=h.r;p.inHouse=true;p.leaving=false;p.eaten=false;p.relT=i*GHOST_REL_INTERVAL+GHOST_REL_OFFSET;p.moving=false});
|
||||||
|
this._endChall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickMeds(){
|
||||||
|
for(const m of this.meds){
|
||||||
|
if(m.eaten){if(m.respT>0&&--m.respT<=0){m.eaten=false;m.pulse=0}}
|
||||||
|
else{m.pulse+=0.08}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickChall(){
|
||||||
|
if(!this.challActive){if(--this.nextChallIn<=0)this._startChall()}
|
||||||
|
else{if(--this.challTimer<=0)this._failChall()}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startChall(){
|
||||||
|
if(!MED_SPAWNS||!MED_SPAWNS.length)return;
|
||||||
|
const gi=this.pains.findIndex(p=>p.dormant);
|
||||||
|
if(gi<0)return;
|
||||||
|
// pick patient position, far from player
|
||||||
|
const cells=[];
|
||||||
|
for(let r=1;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)cells.push({r,c});
|
||||||
|
if(!cells.length)return;
|
||||||
|
const pc=cells[0|Math.random()*cells.length];
|
||||||
|
// activate a random med spawn
|
||||||
|
const avail=this.meds.filter(m=>m.eaten);
|
||||||
|
if(!avail.length)return;
|
||||||
|
const med=avail[0|Math.random()*avail.length];
|
||||||
|
med.eaten=false;med.pulse=0;
|
||||||
|
// activate ghost
|
||||||
|
const g=this.pains[gi];
|
||||||
|
g.dormant=false;g.inHouse=true;g.leaving=false;g.eaten=false;
|
||||||
|
g.relT=FPS*CHALL_DURATION;
|
||||||
|
this.patient={r:pc.r,c:pc.c};
|
||||||
|
this.challActive=true;this.challTimer=FPS*CHALL_DURATION;this.challGhostIdx=gi;
|
||||||
|
}
|
||||||
|
|
||||||
|
_succChall(){
|
||||||
|
const g=this.pains[this.challGhostIdx];
|
||||||
|
this.fx.push({x:g.x+TILE/2,y:g.y+TILE/2,co:'#00ff88',t:40,mx:40,tp:'burst'});
|
||||||
|
g.eaten=true;g.dormant=true;g.inHouse=true;g.leaving=false;g.relT=999999;
|
||||||
|
this.lsc+=SC_CHALL;this.snd.laser();
|
||||||
|
this._endChall();
|
||||||
|
}
|
||||||
|
|
||||||
|
_failChall(){
|
||||||
|
if(this.challGhostIdx>=0)this.pains[this.challGhostIdx].relT=0;
|
||||||
|
this._endChall();
|
||||||
|
}
|
||||||
|
|
||||||
|
_endChall(){
|
||||||
|
this.meds.filter(m=>!m.eaten).forEach(m=>{m.eaten=true;});
|
||||||
|
this.patient=null;
|
||||||
|
this.challActive=false;this.challTimer=0;this.challGhostIdx=-1;
|
||||||
|
this.hasMed=false;
|
||||||
|
this.nextChallIn=(FPS*(CHALL_NEXT_MIN+Math.random()*(CHALL_NEXT_MAX-CHALL_NEXT_MIN)))|0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_chkDone(){
|
||||||
|
if(this.eDots>=this.nDots){
|
||||||
|
this.sc+=this.lsc;this.lsc=0;this.tt+=this.lt;this.td+=this.ld;this.snd.lvlUp();
|
||||||
|
if(this.lv>=WIN_LEVEL){this.state='win';setTimeout(()=>this._ov('win'),800)}
|
||||||
|
else{this.state='lvlDone';setTimeout(()=>this._ov('lvlDone'),800)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load',()=>new Game());
|
||||||
187
Cyst_Kid/js/movement.js
Normal file
187
Cyst_Kid/js/movement.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// ========== 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;
|
||||||
|
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;
|
||||||
|
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+=SC_DOT;if(this.fr%4===0)this.snd.bite()}
|
||||||
|
// Pick up med
|
||||||
|
if(!this.hasMed){for(const m of this.meds){if(!m.eaten&&m.r===p.gr&&m.c===p.gc){
|
||||||
|
m.eaten=true;this.hasMed=true;this.snd.laser();
|
||||||
|
this.fx.push({x:p.x+TILE/2,y:p.y+TILE/2,co:'#EC4899',t:30,mx:30,tp:'glow'});
|
||||||
|
}}}
|
||||||
|
// Deliver med to patient
|
||||||
|
if(this.hasMed&&this.patient&&p.gr===this.patient.r&&p.gc===this.patient.c){
|
||||||
|
this._succChall();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// -------- 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*GHOST_SCARED_MULT: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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
139
Cyst_Kid/js/render.js
Normal file
139
Cyst_Kid/js/render.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// ========== 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._dDots(c);this._dMed(c);this._dPatient(c);this._dPain(c);this._dPl(c);this._dFx(c);
|
||||||
|
if(this.challActive){
|
||||||
|
const pct=this.challTimer/(FPS*CHALL_DURATION),bw=CW-16,bx=8,by=HUD_TOP-5;
|
||||||
|
c.fillStyle='rgba(0,0,0,0.5)';c.fillRect(bx,by,bw,4);
|
||||||
|
c.fillStyle=pct>0.5?'#22c55e':pct>0.25?'#f59e0b':'#ef4444';
|
||||||
|
c.fillRect(bx,by,bw*pct,4);
|
||||||
|
}
|
||||||
|
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)}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_dPatient(c){
|
||||||
|
if(!this.patient)return;
|
||||||
|
const x=this.patient.c*TILE+TILE/2,y=this.patient.r*TILE+HUD_TOP+TILE/2;
|
||||||
|
c.save();c.translate(x,y);
|
||||||
|
// pulsing glow
|
||||||
|
c.globalAlpha=0.25+0.2*Math.abs(Math.sin(this.fr*0.08));
|
||||||
|
c.fillStyle='#EC4899';c.beginPath();c.arc(0,0,10,0,Math.PI*2);c.fill();
|
||||||
|
c.globalAlpha=1;
|
||||||
|
const sc=0.9+0.08*Math.sin(this.fr*0.1);c.scale(sc,sc);
|
||||||
|
// pain aura
|
||||||
|
c.strokeStyle='#EF4444';c.lineWidth=0.8;
|
||||||
|
for(let i=0;i<6;i++){const a=i*Math.PI/3;c.beginPath();c.moveTo(Math.cos(a)*5,Math.sin(a)*5);c.lineTo(Math.cos(a)*9,Math.sin(a)*9);c.stroke()}
|
||||||
|
// head
|
||||||
|
c.fillStyle='#FCD9B6';c.beginPath();c.arc(0,-4,3,0,Math.PI*2);c.fill();
|
||||||
|
// torso
|
||||||
|
c.fillStyle='#93C5FD';c.fillRect(-2,-1,5,6);
|
||||||
|
// legs
|
||||||
|
c.fillStyle='#FCD9B6';c.fillRect(-3,5,2,4);c.fillRect(1,5,2,4);
|
||||||
|
// arms raised
|
||||||
|
c.strokeStyle='#FCD9B6';c.lineWidth=1.5;
|
||||||
|
c.beginPath();c.moveTo(-2,1);c.lineTo(-8,-3);c.stroke();
|
||||||
|
c.beginPath();c.moveTo(3,1);c.lineTo(9,-3);c.stroke();
|
||||||
|
// sad eyebrows
|
||||||
|
c.strokeStyle='#555';c.lineWidth=0.8;
|
||||||
|
c.beginPath();c.moveTo(-2,-6);c.lineTo(-1,-5);c.stroke();
|
||||||
|
c.beginPath();c.moveTo(2,-6);c.lineTo(1,-5);c.stroke();
|
||||||
|
// eyes
|
||||||
|
c.fillStyle='#333';c.fillRect(-2,-5,1,1);c.fillRect(1,-5,1,1);
|
||||||
|
// frown
|
||||||
|
c.strokeStyle='#8B0000';c.lineWidth=0.8;
|
||||||
|
c.beginPath();c.arc(0,-2,1.5,0,Math.PI,true);c.stroke();
|
||||||
|
c.restore();
|
||||||
|
},
|
||||||
|
|
||||||
|
_dMed(c){
|
||||||
|
for(const m of this.meds){
|
||||||
|
if(m.eaten)continue;
|
||||||
|
const x=m.c*TILE+TILE/2,y=m.r*TILE+HUD_TOP+TILE/2;
|
||||||
|
const sc=1+0.06*Math.sin(m.pulse);
|
||||||
|
c.save();c.translate(x,y);c.scale(sc,sc);
|
||||||
|
const pw=14,ph=7,r=ph/2;
|
||||||
|
// left (red) half
|
||||||
|
c.fillStyle='#EF4444';
|
||||||
|
c.beginPath();c.arc(-pw/2+r,0,r,Math.PI/2,Math.PI*3/2);c.lineTo(0,-r);c.lineTo(0,r);c.closePath();c.fill();
|
||||||
|
// right (white) half
|
||||||
|
c.fillStyle='#FFF';
|
||||||
|
c.beginPath();c.arc(pw/2-r,0,r,-Math.PI/2,Math.PI/2);c.lineTo(0,r);c.lineTo(0,-r);c.closePath();c.fill();
|
||||||
|
// outline
|
||||||
|
c.strokeStyle='rgba(0,0,0,0.4)';c.lineWidth=0.5;
|
||||||
|
c.beginPath();c.arc(-pw/2+r,0,r,Math.PI/2,Math.PI*3/2);c.arc(pw/2-r,0,r,-Math.PI/2,Math.PI/2);c.closePath();c.stroke();
|
||||||
|
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)},
|
||||||
|
|
||||||
|
|
||||||
|
_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);
|
||||||
|
const mo=p.mouth*.45;c.fillStyle=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();
|
||||||
|
if(this.hasMed){
|
||||||
|
c.save();c.translate(x,y-r-6);
|
||||||
|
const pw=7,ph=4,pr=ph/2;
|
||||||
|
c.fillStyle='#EF4444';c.beginPath();c.arc(-pw/2+pr,0,pr,Math.PI/2,Math.PI*3/2);c.lineTo(0,-pr);c.lineTo(0,pr);c.closePath();c.fill();
|
||||||
|
c.fillStyle='#FFF';c.beginPath();c.arc(pw/2-pr,0,pr,-Math.PI/2,Math.PI/2);c.lineTo(0,pr);c.lineTo(0,-pr);c.closePath();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);
|
||||||
|
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()}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
29
Cyst_Kid/js/screen-game-over.js
Normal file
29
Cyst_Kid/js/screen-game-over.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ========== 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('go-submitScoreBtn').style.display='';
|
||||||
|
document.getElementById('go-playerName').style.display='';
|
||||||
|
document.getElementById('go-ranking-table-container').innerHTML='';
|
||||||
|
document.getElementById('restartBtn').style.display='none';
|
||||||
|
document.getElementById('game-over-screen').style.display='flex';
|
||||||
|
document.getElementById('go-playerName').value='';
|
||||||
|
document.getElementById('go-playerName').focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
_subGO(){
|
||||||
|
const nm=document.getElementById('go-playerName').value.trim()||'Anonym';
|
||||||
|
this.rank.push({n:nm,s:this.sc,t:-1,d:-1});
|
||||||
|
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));
|
||||||
|
document.getElementById('go-ranking-table-container').innerHTML=this._rankingHTML();
|
||||||
|
document.getElementById('go-submitScoreBtn').style.display='none';
|
||||||
|
document.getElementById('go-playerName').style.display='none';
|
||||||
|
document.getElementById('go-playerName').blur();
|
||||||
|
document.getElementById('restartBtn').style.display='inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
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;
|
||||||
|
document.getElementById('level-complete-screen').style.display='flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
16
Cyst_Kid/js/screen-start.js
Normal file
16
Cyst_Kid/js/screen-start.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// ========== 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(){
|
||||||
|
this.state='start';
|
||||||
|
document.getElementById('start-screen').style.display='flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
38
Cyst_Kid/js/screen-win.js
Normal file
38
Cyst_Kid/js/screen-win.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// ========== WIN SCREEN ==========
|
||||||
|
// Extends Game.prototype — must be loaded after game.js
|
||||||
|
|
||||||
|
Object.assign(Game.prototype, {
|
||||||
|
|
||||||
|
_showWin(){
|
||||||
|
document.getElementById('win-score-text').textContent='Punkte: '+this.sc;
|
||||||
|
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').value='';
|
||||||
|
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));
|
||||||
|
document.getElementById('ranking-table-container').innerHTML=this._rankingHTML();
|
||||||
|
document.getElementById('submitScoreBtn').style.display='none';
|
||||||
|
document.getElementById('playerName').style.display='none';
|
||||||
|
document.getElementById('playerName').blur();
|
||||||
|
document.getElementById('playAgainBtn').style.display='inline-block';
|
||||||
|
},
|
||||||
|
|
||||||
|
_rankingHTML(){
|
||||||
|
let h='<table><tr><th>#</th><th>Name</th><th>Punkte</th><th>Zeit</th></tr>';
|
||||||
|
this.rank.forEach((r,i)=>{
|
||||||
|
const time=r.t<0?'-:--':`${Math.floor(r.t/FPS/60)}:${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>${time}</td></tr>`;
|
||||||
|
});
|
||||||
|
return h+'</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
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))}
|
||||||
|
}
|
||||||
887
Cyst_Kid/level-designer.html
Normal file
887
Cyst_Kid/level-designer.html
Normal file
@@ -0,0 +1,887 @@
|
|||||||
|
<!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>
|
||||||
|
<button class="tool-btn full" onclick="setSpecial('medSpawn')">💊 Med-Spawn</button>
|
||||||
|
</div>
|
||||||
|
<div class="legend" style="margin-top:8px">
|
||||||
|
<span>Cyan ●</span> = Spielerstart<br>
|
||||||
|
<span>Gold ●</span> = Geist-Startpos<br>
|
||||||
|
<span>Pink ●</span> = Med-Spawnpunkt
|
||||||
|
</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 medSpawns = (typeof MED_SPAWNS !== 'undefined') ? MED_SPAWNS.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()));
|
||||||
|
medSpawns.forEach(sp => drawMarker(ctxG, sp.r, sp.c, '#EC4899', 'M'));
|
||||||
|
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' :
|
||||||
|
mode === 'medSpawn' ? 'Klick: Med-Spawnpunkt hinzufügen / entfernen' :
|
||||||
|
'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`;
|
||||||
|
}
|
||||||
|
} else if (specialMode === 'medSpawn') {
|
||||||
|
const idx = medSpawns.findIndex(sp => sp.r === r && sp.c === c);
|
||||||
|
if (idx >= 0) {
|
||||||
|
medSpawns.splice(idx, 1);
|
||||||
|
document.getElementById('info-bar').textContent = `Med-Spawn entfernt bei [${r}, ${c}] | Gesamt: ${medSpawns.length}`;
|
||||||
|
} else {
|
||||||
|
medSpawns.push({ r, c });
|
||||||
|
document.getElementById('info-bar').textContent = `Med-Spawn gesetzt bei [${r}, ${c}] | Gesamt: ${medSpawns.length}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}];`);
|
||||||
|
const ms = medSpawns.map(s => `{r:${s.r},c:${s.c}}`).join(',');
|
||||||
|
lines.push(`const MED_SPAWNS = [${ms}];`);
|
||||||
|
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>
|
||||||
270
Cyst_Kid/style.css
Normal file
270
Cyst_Kid/style.css
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/* ========================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-score-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
320
index.html
Normal file
320
index.html
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Kaltaquise Games</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap');
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #050a0f;
|
||||||
|
--surface: #0c1520;
|
||||||
|
--border: #1a3a5c;
|
||||||
|
--cyan: #00e5ff;
|
||||||
|
--green: #39ff14;
|
||||||
|
--magenta: #ff00c8;
|
||||||
|
--yellow: #ffe600;
|
||||||
|
--text: #c8dae8;
|
||||||
|
--muted: #4a6880;
|
||||||
|
}
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Share Tech Mono', 'Courier New', monospace;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── scanline overlay ── */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(0,0,0,.18) 2px,
|
||||||
|
rgba(0,0,0,.18) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── header ── */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3.5rem 1rem 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-line {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: clamp(1.8rem, 6vw, 3.6rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--cyan);
|
||||||
|
text-shadow:
|
||||||
|
0 0 8px var(--cyan),
|
||||||
|
0 0 24px var(--cyan),
|
||||||
|
0 0 60px rgba(0,229,255,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-sub {
|
||||||
|
font-size: clamp(.75rem, 2vw, 1rem);
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: .35em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: min(500px, 90%);
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--border), var(--cyan), var(--border), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── grid ── */
|
||||||
|
.game-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── card ── */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2rem 1.75rem 1.75rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, transparent 70%, rgba(255,255,255,.02));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: var(--card-color, var(--cyan));
|
||||||
|
box-shadow: 0 0 20px -4px var(--card-color, var(--cyan));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--cyst { --card-color: var(--magenta); }
|
||||||
|
.card--kidney { --card-color: var(--cyan); }
|
||||||
|
.card--digger { --card-color: var(--green); }
|
||||||
|
.card--pong { --card-color: var(--yellow); }
|
||||||
|
|
||||||
|
.card-badge {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: .6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .25em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--card-color, var(--cyan));
|
||||||
|
margin-bottom: .9rem;
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 12px var(--card-color, var(--cyan));
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: .82rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: .85;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .15em;
|
||||||
|
color: var(--card-color, var(--cyan));
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid var(--card-color, var(--cyan));
|
||||||
|
padding: .4em 1em;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .play-btn {
|
||||||
|
background: var(--card-color, var(--cyan));
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── tag pills ── */
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: .65rem;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
padding: .2em .65em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── corner decoration ── */
|
||||||
|
.card-corner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; right: 0;
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
border-left: 1px solid var(--card-color, var(--cyan));
|
||||||
|
border-bottom: 1px solid var(--card-color, var(--cyan));
|
||||||
|
opacity: .25;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── footer ── */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: .1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer span { color: var(--border); margin: 0 .5em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="logo-line">Kaltaquise Games</div>
|
||||||
|
<div class="logo-sub">Medizin & Arcade — Spielekatalog</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="game-grid">
|
||||||
|
|
||||||
|
<!-- Cyst Kid -->
|
||||||
|
<a class="card card--cyst" href="/games/Cyst_Kid/index.html">
|
||||||
|
<div class="card-corner"></div>
|
||||||
|
<div class="card-badge">Game 01</div>
|
||||||
|
<div class="card-title">Cyst Kid</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
Schlüpf in die Rolle des Cyst Kids — sammle Punkte, weiche den Schmerzen aus und bringe Medikamente rechtzeitig zum Patienten. Schnelle Reflexe entscheiden!
|
||||||
|
</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Action</span>
|
||||||
|
<span class="tag">Multiplattform</span>
|
||||||
|
<span class="tag">Medizin</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span>Pfeiltasten / WASD</span>
|
||||||
|
<span class="play-btn">Spielen ›</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Kidney Lab -->
|
||||||
|
<a class="card card--kidney" href="/games/kidney_lab/index.html">
|
||||||
|
<div class="card-corner"></div>
|
||||||
|
<div class="card-badge">Game 02</div>
|
||||||
|
<div class="card-title">Kidney Lab</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
Inspiriert von Game & Watch MW-56 (1983). Sammle Nieren von drei Förderbändern und liefere sie ins Labor — aber pass auf Spendernieren auf, die wertvolle Medikamente produzieren.
|
||||||
|
</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Retro</span>
|
||||||
|
<span class="tag">2 Spieler</span>
|
||||||
|
<span class="tag">Highscore</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span>Pfeiltasten · WASD · Touch</span>
|
||||||
|
<span class="play-btn">Spielen ›</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Thiola Pong -->
|
||||||
|
<a class="card card--pong" href="/games/thiola-pong/pong-thiola.html">
|
||||||
|
<div class="card-corner"></div>
|
||||||
|
<div class="card-badge">Game 04</div>
|
||||||
|
<div class="card-title">Thiola Pong</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
Klassisches Pong neu interpretiert — Thiola Edition. Zwei Schläger, ein Ball, pure Reflexe. Spiele gegen die KI oder fordere einen zweiten Spieler heraus.
|
||||||
|
</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Pong</span>
|
||||||
|
<span class="tag">2 Spieler</span>
|
||||||
|
<span class="tag">Klassiker</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span>Pfeiltasten · W/S</span>
|
||||||
|
<span class="play-btn">Spielen ›</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Sys Digger -->
|
||||||
|
<a class="card card--digger" href="/games/sys_digger/index.html">
|
||||||
|
<div class="card-corner"></div>
|
||||||
|
<div class="card-badge">Game 03</div>
|
||||||
|
<div class="card-title">Sys Digger</div>
|
||||||
|
<div class="card-desc">
|
||||||
|
Verhindere, dass fremde Atome an ein Molekül andocken und einen Cystinstein bilden. Klicke rote Ankerpunkte ab, bevor die Atome landen — die Geschwindigkeit steigt ständig.
|
||||||
|
</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Reaktion</span>
|
||||||
|
<span class="tag">Whac-a-Mole</span>
|
||||||
|
<span class="tag">Highscore</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span>Mausklick</span>
|
||||||
|
<span class="play-btn">Spielen ›</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Kaltaquise Gamification<span>·</span>MW-56 inspired<span>·</span>Browser Games
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</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}}
|
||||||
59
sys_digger/CLAUDE.md
Normal file
59
sys_digger/CLAUDE.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 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 — no build step, no server required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
There are no tests, no linter, and no package manager.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Pure vanilla JS/HTML/CSS game. No frameworks, no modules — all files are loaded as classic `<script>` tags and share a single global scope. **Load order matters**:
|
||||||
|
|
||||||
|
```
|
||||||
|
constants.js → atom.js → render.js → game.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### File responsibilities
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `constants.js` | Single source of truth for every tunable value (speeds, counts, radii, timing). Edit here first when tweaking gameplay feel. |
|
||||||
|
| `atom.js` | `IncomingAtom` class — construction, per-frame movement, docking logic, self-draw. Reads globals from `game.js` (`canvas`, `CX`, `CY`, `anchorPoints`, `docks`, `gameOver`) and calls `burst()`, `updateCystinstein()`, `triggerGameOver()` from `render.js`/`game.js`. |
|
||||||
|
| `render.js` | All canvas drawing: background, central molecule, anchor points, particles, click effects, and the Cystinstein side-panel. Also owns `burst()` (particle spawner) and `updateCystinstein()`. Reads `ctx`, `cysCtx`, `CX`, `CY`, `anchorPoints`, `particles`, `clickEffects`, `docks` from `game.js`. |
|
||||||
|
| `game.js` | Game state, `requestAnimationFrame` loop, input handling, leaderboard (localStorage), screen transitions (intro → game → game-over → intro). Declares all shared mutable globals consumed by the other files. |
|
||||||
|
| `index.html` | CSS + HTML structure only. Houses the intro screen, attract screen, game-over overlay, leaderboard table, and the kidney-shaped Cystinstein side panel (inline SVG border). |
|
||||||
|
|
||||||
|
### Global state (declared in `game.js`, used everywhere)
|
||||||
|
|
||||||
|
`canvas`, `ctx`, `cysCanvas`, `cysCtx`, `CX`, `CY`, `score`, `docks`, `gameOver`, `gameStarted`, `incomingAtoms`, `anchorPoints`, `particles`, `clickEffects`, `stars`.
|
||||||
|
|
||||||
|
### Screen flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Boot → Intro ──(30 s idle)──→ Attract (highscore)
|
||||||
|
←─(any key/click)─
|
||||||
|
│
|
||||||
|
└─(START clicked)──→ Game loop
|
||||||
|
│
|
||||||
|
(6 docks)──→ Game-over overlay
|
||||||
|
│
|
||||||
|
(Play Again)──→ Intro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Leaderboard persistence
|
||||||
|
|
||||||
|
Stored in `localStorage` under key `sysdigger_leaderboard` as a JSON array of `{name, score}` objects, capped at 10 entries, sorted descending by score.
|
||||||
|
|
||||||
|
### Key design constraints
|
||||||
|
|
||||||
|
- Anchor points activate (turn red) only when the incoming atom **enters the visible viewport**, not at spawn time — see `IncomingAtom.update()` in `atom.js`.
|
||||||
|
- Difficulty ramps automatically: `spawnInterval` decreases by `SPAWN_DIFFICULTY_STEP` each spawn, floored at `SPAWN_INTERVAL_MIN`.
|
||||||
|
- The custom cursor is a data-URL SVG pill embedded directly in CSS on the `canvas` selector.
|
||||||
|
- The Cystinstein side panel uses an inline SVG with a bezier kidney path for its border; the interior atom-cluster is drawn on a separate `<canvas id="cystinstein-canvas">` by `updateCystinstein()` in `render.js`.
|
||||||
84
sys_digger/atom.js
Normal file
84
sys_digger/atom.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
class IncomingAtom {
|
||||||
|
constructor(anchorIndex) {
|
||||||
|
this.anchorIndex = anchorIndex;
|
||||||
|
const ap = anchorPoints[anchorIndex];
|
||||||
|
const far = Math.max(canvas.width, canvas.height) * 0.9;
|
||||||
|
this.x = CX + Math.cos(ap.angle) * far;
|
||||||
|
this.y = CY + Math.sin(ap.angle) * far;
|
||||||
|
this.tx = ap.x;
|
||||||
|
this.ty = ap.y;
|
||||||
|
this.speed = ATOM_SPEED_MIN + Math.random() * (ATOM_SPEED_MAX - ATOM_SPEED_MIN);
|
||||||
|
this.radius = ATOM_RADIUS_MIN + Math.random() * (ATOM_RADIUS_MAX - ATOM_RADIUS_MIN);
|
||||||
|
this.hue = ATOM_HUE_MIN + Math.random() * ATOM_HUE_RANGE;
|
||||||
|
this.dead = false;
|
||||||
|
this.eAngle = Math.random() * Math.PI * 2;
|
||||||
|
this.eSpeed = ATOM_ELECTRON_SPEED_MIN + Math.random() * (ATOM_ELECTRON_SPEED_MAX - ATOM_ELECTRON_SPEED_MIN);
|
||||||
|
// ap.active is set once the atom enters the visible screen (see update)
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.dead || gameOver) return;
|
||||||
|
|
||||||
|
// Activate the anchor the moment the atom becomes visible
|
||||||
|
const ap = anchorPoints[this.anchorIndex];
|
||||||
|
if (!ap.active &&
|
||||||
|
this.x >= 0 && this.x <= canvas.width &&
|
||||||
|
this.y >= 0 && this.y <= canvas.height) {
|
||||||
|
ap.active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = this.tx - this.x;
|
||||||
|
const dy = this.ty - this.y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
if (dist < this.speed) {
|
||||||
|
this.dead = true;
|
||||||
|
anchorPoints[this.anchorIndex].active = false;
|
||||||
|
docks++;
|
||||||
|
document.getElementById('docks-count').textContent = `${docks} / ${MAX_DOCKS} docks`;
|
||||||
|
updateCystinstein();
|
||||||
|
burst(this.tx, this.ty, '#ff4444');
|
||||||
|
if (docks >= MAX_DOCKS) triggerGameOver();
|
||||||
|
} else {
|
||||||
|
this.x += (dx / dist) * this.speed;
|
||||||
|
this.y += (dy / dist) * this.speed;
|
||||||
|
this.eAngle += this.eSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (this.dead) return;
|
||||||
|
const c = `hsl(${this.hue}, 80%, 60%)`;
|
||||||
|
|
||||||
|
const g = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, this.radius * 2.5);
|
||||||
|
g.addColorStop(0, `hsla(${this.hue}, 80%, 60%, 0.4)`);
|
||||||
|
g.addColorStop(1, 'transparent');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.radius * 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = c;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, this.radius * 0.55, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(this.x, this.y);
|
||||||
|
ctx.strokeStyle = `hsla(${this.hue}, 80%, 70%, 0.35)`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 0, this.radius, this.radius * 0.4, this.eAngle * 0.25, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
Math.cos(this.eAngle) * this.radius,
|
||||||
|
Math.sin(this.eAngle) * (this.radius * 0.4),
|
||||||
|
3, 0, Math.PI * 2
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
33
sys_digger/beschreibung.md
Normal file
33
sys_digger/beschreibung.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Sys Digger – Spielbeschreibung
|
||||||
|
|
||||||
|
## Was ist Sys Digger?
|
||||||
|
|
||||||
|
Sys Digger ist ein browserbasiertes Reaktionsspiel, das lose auf dem bekannten Arcade-Klassiker **Whac-a-Mole** basiert. Statt Maulwürfe zu treffen, geht es hier jedoch um Chemie und Moleküle: Der Spieler muss ein zentrales Atom davor schützen, zu einem gefährlichen **Cystinstein** heranzuwachsen.
|
||||||
|
|
||||||
|
## Spielprinzip
|
||||||
|
|
||||||
|
Im Zentrum des Bildschirms befindet sich ein Molekül mit mehreren **Ankerpunkten** um sich herum. Von den Rändern des Bildschirms nähern sich fremde Atome und versuchen, an diesen Ankerpunkten anzudocken. Gelingt eine Verbindung, wächst das Molekül ein Stück weiter in Richtung Cystinstein.
|
||||||
|
|
||||||
|
Der Spieler muss dies verhindern – und zwar durch gezieltes Klicken auf den leuchtend roten Ankerpunkt, bevor das anfliegende Atom dort andockt.
|
||||||
|
|
||||||
|
## Steuerung
|
||||||
|
|
||||||
|
- **Mausklick** auf einen rot markierten Ankerpunkt, sobald sich ein Atom nähert.
|
||||||
|
- Trifft der Klick rechtzeitig, wird die Verbindung blockiert und der Spieler erhält einen **Punkt**.
|
||||||
|
- Kommt der Klick zu spät oder wird der Ankerpunkt verfehlt, dockt das Atom an.
|
||||||
|
|
||||||
|
## Spielverlauf
|
||||||
|
|
||||||
|
- Je länger das Spiel andauert, desto schneller erscheinen neue Atome – die Schwierigkeit steigt kontinuierlich.
|
||||||
|
- In der **oberen linken Ecke** befindet sich ein kleines Panel, das den aktuellen Zustand des Cystinsteins zeigt. Mit jeder erfolgreichen Andockung wächst dort ein neues Atom hinzu.
|
||||||
|
- Wurden **6 Andockungen** nicht verhindert, ist der Cystinstein vollständig gebildet – das Spiel ist verloren.
|
||||||
|
|
||||||
|
## Highscore
|
||||||
|
|
||||||
|
Nach jedem Spiel kann der Spieler seinen Namen eingeben und seinen Punktestand in der **Bestenliste** speichern. Die Top-10-Ergebnisse werden dauerhaft gespeichert und sind beim nächsten Besuch wieder abrufbar.
|
||||||
|
|
||||||
|
Bleibt der Startbildschirm 30 Sekunden unberührt, wechselt das Spiel automatisch in eine **Highscore-Ansicht**, die die aktuelle Bestenliste anzeigt. Ein Klick oder ein Tastendruck bringt den Startbildschirm zurück.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
So viele Andockversuche wie möglich abwehren, den Highscore knacken – und verhindern, dass der Cystinstein entsteht.
|
||||||
50
sys_digger/constants.js
Normal file
50
sys_digger/constants.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// ─── Game rules ──────────────────────────────────────────────────────────────
|
||||||
|
const MAX_DOCKS = 6; // failed docks before game over
|
||||||
|
const NUM_ANCHORS = 6; // anchor points around the central molecule
|
||||||
|
const ANCHOR_RADIUS = 120; // px distance from center to anchor points
|
||||||
|
const CLICK_RADIUS = 38; // px hit radius for clicking an anchor
|
||||||
|
|
||||||
|
// ─── Spawn timing ────────────────────────────────────────────────────────────
|
||||||
|
const SPAWN_INTERVAL_START = 2500; // ms between spawns at the start
|
||||||
|
const SPAWN_INTERVAL_MIN = 700; // ms floor (fastest difficulty)
|
||||||
|
const SPAWN_DIFFICULTY_STEP = 18; // ms reduction per spawn
|
||||||
|
|
||||||
|
// ─── Incoming atom ───────────────────────────────────────────────────────────
|
||||||
|
const ATOM_SPEED_MIN = 1.4; // px/frame minimum
|
||||||
|
const ATOM_SPEED_MAX = 3.2; // px/frame maximum (min + random range)
|
||||||
|
const ATOM_RADIUS_MIN = 16; // px nucleus radius minimum
|
||||||
|
const ATOM_RADIUS_MAX = 24; // px nucleus radius maximum (min + random range)
|
||||||
|
const ATOM_HUE_MIN = 180; // hsl hue range start (blue-cyan)
|
||||||
|
const ATOM_HUE_RANGE = 60; // hsl hue range width
|
||||||
|
const ATOM_ELECTRON_SPEED_MIN = 0.05;
|
||||||
|
const ATOM_ELECTRON_SPEED_MAX = 0.08;
|
||||||
|
|
||||||
|
// ─── Particles ───────────────────────────────────────────────────────────────
|
||||||
|
const BURST_COUNT = 20; // particles per burst
|
||||||
|
const BURST_SPEED_MIN = 2;
|
||||||
|
const BURST_SPEED_MAX = 7;
|
||||||
|
const PARTICLE_DECAY_MIN = 0.022;
|
||||||
|
const PARTICLE_DECAY_MAX = 0.047;
|
||||||
|
const PARTICLE_RADIUS_MIN = 2;
|
||||||
|
const PARTICLE_RADIUS_MAX = 6;
|
||||||
|
|
||||||
|
// ─── Anchor block flash ───────────────────────────────────────────────────────
|
||||||
|
const BLOCK_TIMER_FRAMES = 20; // frames the green flash lasts after a block
|
||||||
|
|
||||||
|
// ─── Stars ───────────────────────────────────────────────────────────────────
|
||||||
|
const STAR_COUNT = 160;
|
||||||
|
const STAR_ALPHA_MIN = 0.15;
|
||||||
|
const STAR_ALPHA_RANGE = 0.55;
|
||||||
|
const STAR_RADIUS_MAX = 1.5;
|
||||||
|
|
||||||
|
// ─── Central nucleus ─────────────────────────────────────────────────────────
|
||||||
|
const NUCLEUS_RADIUS = 52; // px
|
||||||
|
const NUCLEUS_ORBITS = 3; // electron orbits to draw
|
||||||
|
|
||||||
|
// ─── Cystinstein indicator ────────────────────────────────────────────────────
|
||||||
|
const CYS_CENTER_X = 65; // px within the 130×130 side canvas
|
||||||
|
const CYS_CENTER_Y = 65;
|
||||||
|
const CYS_CORE_RADIUS = 14; // central atom radius
|
||||||
|
const CYS_DOCK_RADIUS = 28; // distance from center to docked atoms
|
||||||
|
const CYS_ATOM_RADIUS = 9; // docked atom radius
|
||||||
|
const CYS_RING_RADIUS = 48; // final danger ring radius
|
||||||
294
sys_digger/game.js
Normal file
294
sys_digger/game.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
const canvas = document.getElementById('gameCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const cysCanvas = document.getElementById('cystinstein-canvas');
|
||||||
|
const cysCtx = cysCanvas.getContext('2d');
|
||||||
|
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
const CX = canvas.width / 2;
|
||||||
|
const CY = canvas.height / 2;
|
||||||
|
|
||||||
|
// ─── Leaderboard ─────────────────────────────────────────────────────────────
|
||||||
|
const LEADERBOARD_KEY = 'sysdigger_leaderboard';
|
||||||
|
const LEADERBOARD_MAX = 10;
|
||||||
|
|
||||||
|
function loadLeaderboard() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(LEADERBOARD_KEY)) || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLeaderboard(board) {
|
||||||
|
localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(board));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntry(name, score) {
|
||||||
|
const board = loadLeaderboard();
|
||||||
|
board.push({ name: name.trim() || 'Anonymous', score });
|
||||||
|
board.sort((a, b) => b.score - a.score);
|
||||||
|
const trimmed = board.slice(0, LEADERBOARD_MAX);
|
||||||
|
saveLeaderboard(trimmed);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeaderboard(highlightEntry) {
|
||||||
|
const board = loadLeaderboard();
|
||||||
|
const tbody = document.getElementById('leaderboard-rows');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (board.length === 0) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td colspan="3" style="color:#555;text-align:center;padding:12px">No scores yet</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
board.forEach((entry, i) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const isHighlight = highlightEntry &&
|
||||||
|
entry.name === highlightEntry.name &&
|
||||||
|
entry.score === highlightEntry.score;
|
||||||
|
|
||||||
|
if (isHighlight) tr.classList.add('highlight');
|
||||||
|
else if (i === 0) tr.classList.add('rank-1');
|
||||||
|
else if (i === 1) tr.classList.add('rank-2');
|
||||||
|
else if (i === 2) tr.classList.add('rank-3');
|
||||||
|
|
||||||
|
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : i + 1;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="rank-num">${medal}</td>
|
||||||
|
<td style="text-align:left">${escapeHtml(entry.name)}</td>
|
||||||
|
<td style="text-align:right">${entry.score}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function topScore() {
|
||||||
|
const board = loadLeaderboard();
|
||||||
|
return board.length > 0 ? board[0].score : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
let score = 0;
|
||||||
|
let docks = 0;
|
||||||
|
let gameOver = false;
|
||||||
|
let gameStarted = false;
|
||||||
|
let incomingAtoms = [];
|
||||||
|
let anchorPoints = [];
|
||||||
|
let particles = [];
|
||||||
|
let clickEffects = [];
|
||||||
|
let lastSpawn = 0;
|
||||||
|
let spawnInterval = SPAWN_INTERVAL_START;
|
||||||
|
|
||||||
|
const stars = Array.from({ length: STAR_COUNT }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
r: Math.random() * STAR_RADIUS_MAX,
|
||||||
|
a: STAR_ALPHA_MIN + Math.random() * STAR_ALPHA_RANGE
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ─── Anchors ─────────────────────────────────────────────────────────────────
|
||||||
|
function initAnchors() {
|
||||||
|
anchorPoints = [];
|
||||||
|
for (let i = 0; i < NUM_ANCHORS; i++) {
|
||||||
|
const angle = (i / NUM_ANCHORS) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
anchorPoints.push({
|
||||||
|
x: CX + Math.cos(angle) * ANCHOR_RADIUS,
|
||||||
|
y: CY + Math.sin(angle) * ANCHOR_RADIUS,
|
||||||
|
angle,
|
||||||
|
active: false,
|
||||||
|
blockTimer: 0,
|
||||||
|
pulsePhase: Math.random() * Math.PI * 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Spawn ───────────────────────────────────────────────────────────────────
|
||||||
|
function spawnAtom() {
|
||||||
|
const available = anchorPoints.map((_, i) => i).filter(i => !anchorPoints[i].active);
|
||||||
|
if (available.length === 0) return;
|
||||||
|
const idx = available[Math.floor(Math.random() * available.length)];
|
||||||
|
incomingAtoms.push(new IncomingAtom(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ───────────────────────────────────────────────────────────────────
|
||||||
|
canvas.addEventListener('click', e => {
|
||||||
|
if (gameOver) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const mx = e.clientX - rect.left;
|
||||||
|
const my = e.clientY - rect.top;
|
||||||
|
let hit = false;
|
||||||
|
|
||||||
|
anchorPoints.forEach((ap, i) => {
|
||||||
|
if (!ap.active) return;
|
||||||
|
if (Math.hypot(mx - ap.x, my - ap.y) > CLICK_RADIUS) return;
|
||||||
|
|
||||||
|
const atom = incomingAtoms.find(a => a.anchorIndex === i && !a.dead);
|
||||||
|
if (atom) {
|
||||||
|
atom.dead = true;
|
||||||
|
ap.active = false;
|
||||||
|
ap.blockTimer = BLOCK_TIMER_FRAMES;
|
||||||
|
score++;
|
||||||
|
document.getElementById('score').textContent = `Score: ${score}`;
|
||||||
|
burst(ap.x, ap.y, '#00ff88');
|
||||||
|
clickEffects.push({ x: ap.x, y: ap.y, radius: 5, life: 1 });
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hit) clickEffects.push({ x: mx, y: my, radius: 5, life: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Game flow ───────────────────────────────────────────────────────────────
|
||||||
|
function triggerGameOver() {
|
||||||
|
gameOver = true;
|
||||||
|
const best = topScore();
|
||||||
|
const isNewRecord = best === null || score > best;
|
||||||
|
|
||||||
|
document.getElementById('final-score').textContent = `Score: ${score}`;
|
||||||
|
document.getElementById('new-record').classList.toggle('visible', isNewRecord);
|
||||||
|
|
||||||
|
const input = document.getElementById('player-name');
|
||||||
|
input.value = '';
|
||||||
|
setTimeout(() => input.focus(), 50);
|
||||||
|
|
||||||
|
renderLeaderboard(null);
|
||||||
|
document.getElementById('overlay').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitScore() {
|
||||||
|
const name = document.getElementById('player-name').value.trim() || 'Anonymous';
|
||||||
|
const entry = { name, score };
|
||||||
|
addEntry(name, score);
|
||||||
|
renderLeaderboard(entry);
|
||||||
|
|
||||||
|
const best = topScore();
|
||||||
|
updateHudBest(best);
|
||||||
|
|
||||||
|
document.getElementById('name-form').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartGame() {
|
||||||
|
score = docks = 0;
|
||||||
|
gameOver = false;
|
||||||
|
gameStarted = false;
|
||||||
|
incomingAtoms = [];
|
||||||
|
particles = [];
|
||||||
|
clickEffects = [];
|
||||||
|
lastSpawn = 0;
|
||||||
|
spawnInterval = SPAWN_INTERVAL_START;
|
||||||
|
document.getElementById('score').textContent = 'Score: 0';
|
||||||
|
document.getElementById('docks-count').textContent = `0 / ${MAX_DOCKS} docks`;
|
||||||
|
document.getElementById('name-form').style.display = 'flex';
|
||||||
|
document.getElementById('overlay').classList.remove('active');
|
||||||
|
document.getElementById('intro').classList.remove('hidden');
|
||||||
|
startIdleTimer();
|
||||||
|
initAnchors();
|
||||||
|
updateCystinstein();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attract mode ────────────────────────────────────────────────────────────
|
||||||
|
const ATTRACT_DELAY = 30_000;
|
||||||
|
let idleTimer = null;
|
||||||
|
|
||||||
|
function startIdleTimer() {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(showAttract, ATTRACT_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAttract() {
|
||||||
|
document.getElementById('intro').classList.add('hidden');
|
||||||
|
renderAttractLeaderboard();
|
||||||
|
document.getElementById('attract').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showIntro() {
|
||||||
|
document.getElementById('attract').classList.add('hidden');
|
||||||
|
document.getElementById('intro').classList.remove('hidden');
|
||||||
|
startIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttractLeaderboard() {
|
||||||
|
const board = loadLeaderboard();
|
||||||
|
const tbody = document.getElementById('attract-rows');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
if (board.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="3" style="color:#555;text-align:center;padding:16px">No scores yet — be the first!</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
board.forEach((entry, i) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
if (i === 0) tr.classList.add('rank-1');
|
||||||
|
else if (i === 1) tr.classList.add('rank-2');
|
||||||
|
else if (i === 2) tr.classList.add('rank-3');
|
||||||
|
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : i + 1;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="rank-num">${medal}</td>
|
||||||
|
<td style="text-align:left">${escapeHtml(entry.name)}</td>
|
||||||
|
<td style="text-align:right">${entry.score}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissAttract() {
|
||||||
|
if (!document.getElementById('attract').classList.contains('hidden')) {
|
||||||
|
showIntro();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', dismissAttract);
|
||||||
|
document.addEventListener('click', dismissAttract);
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
gameStarted = true;
|
||||||
|
document.getElementById('intro').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHudBest(best) {
|
||||||
|
document.getElementById('highscore').textContent = best !== null ? `Best: ${best}` : 'Best: —';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Enter key on name input ──────────────────────────────────────────────────
|
||||||
|
document.getElementById('player-name').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') submitScore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Main loop ───────────────────────────────────────────────────────────────
|
||||||
|
function gameLoop(ts) {
|
||||||
|
drawBackground();
|
||||||
|
|
||||||
|
if (gameStarted && !gameOver && ts - lastSpawn > spawnInterval) {
|
||||||
|
spawnAtom();
|
||||||
|
lastSpawn = ts;
|
||||||
|
spawnInterval = Math.max(SPAWN_INTERVAL_MIN, spawnInterval - SPAWN_DIFFICULTY_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
incomingAtoms = incomingAtoms.filter(a => !a.dead);
|
||||||
|
incomingAtoms.forEach(a => { a.update(); a.draw(); });
|
||||||
|
|
||||||
|
drawCentralMolecule();
|
||||||
|
drawAnchorPoints();
|
||||||
|
drawParticles();
|
||||||
|
drawClickEffects();
|
||||||
|
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boot ────────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('intro').innerHTML =
|
||||||
|
document.getElementById('intro').innerHTML.replace('${MAX_DOCKS}', MAX_DOCKS);
|
||||||
|
updateHudBest(topScore());
|
||||||
|
startIdleTimer();
|
||||||
|
initAnchors();
|
||||||
|
updateCystinstein();
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
416
sys_digger/index.html
Normal file
416
sys_digger/index.html
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sys Digger</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #0a0a1a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
canvas { display: block; cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32'><g transform='translate(16,16) rotate(-45)'><path d='M 0 -5 L -6 -5 A 5 5 0 0 0 -6 5 L 0 5 Z' fill='%23ff4444'/><path d='M 0 -5 L 6 -5 A 5 5 0 0 1 6 5 L 0 5 Z' fill='%23f5f5f5'/><path d='M -6 -5 L 6 -5 A 5 5 0 0 1 6 5 L -6 5 A 5 5 0 0 0 -6 -5 Z' fill='none' stroke='%23222222' stroke-width='1.2'/><line x1='0' y1='-5' x2='0' y2='5' stroke='%23555555' stroke-width='0.8'/></g></svg>") 16 16, auto; }
|
||||||
|
|
||||||
|
#score {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
color: #00ff88;
|
||||||
|
font-size: 24px;
|
||||||
|
text-shadow: 0 0 10px #00ff88;
|
||||||
|
}
|
||||||
|
#highscore {
|
||||||
|
position: absolute;
|
||||||
|
top: 52px;
|
||||||
|
right: 20px;
|
||||||
|
color: #ffcc00;
|
||||||
|
font-size: 16px;
|
||||||
|
text-shadow: 0 0 8px #ffcc00;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cystinstein-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
width: 160px;
|
||||||
|
border: 2px solid #ff4444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
#cystinstein-label {
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-shadow: 0 0 8px #ff4444;
|
||||||
|
}
|
||||||
|
#cystinstein-canvas { display: block; margin: 0 auto; }
|
||||||
|
#docks-count {
|
||||||
|
color: #ff8888;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Intro screen ── */
|
||||||
|
#intro {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#intro.hidden { display: none; }
|
||||||
|
|
||||||
|
#intro h1 {
|
||||||
|
font-size: 64px;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
color: #00ff88;
|
||||||
|
text-shadow: 0 0 30px #00ff88, 0 0 60px #00ff8855;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
animation: flicker 2s infinite alternate;
|
||||||
|
}
|
||||||
|
#intro .subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: #00ccff;
|
||||||
|
text-shadow: 0 0 10px #00ccff;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
#intro .instructions {
|
||||||
|
max-width: 480px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
#intro .instruction-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
#intro .instruction-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#intro .instruction-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#intro .instruction-text strong { color: #fff; }
|
||||||
|
#start-btn {
|
||||||
|
padding: 16px 52px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
background: #00ff88;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
#start-btn:hover { background: #00cc66; transform: scale(1.04); }
|
||||||
|
|
||||||
|
/* ── Attract / highscore screen ── */
|
||||||
|
#attract {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
#attract.hidden { display: none; }
|
||||||
|
|
||||||
|
#attract h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
color: #ffcc00;
|
||||||
|
text-shadow: 0 0 12px #ffcc00;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
#attract .attract-game-title {
|
||||||
|
font-size: 44px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
color: #00ff88;
|
||||||
|
text-shadow: 0 0 20px #00ff88;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
animation: flicker 2s infinite alternate;
|
||||||
|
}
|
||||||
|
#attract-table {
|
||||||
|
width: 360px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
#attract-table th {
|
||||||
|
color: #555;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
#attract-table td { padding: 8px 10px; color: #ccc; border-bottom: 1px solid #1a1a2e; }
|
||||||
|
#attract-table tr.rank-1 td { color: #ffd700; font-size: 18px; }
|
||||||
|
#attract-table tr.rank-2 td { color: #c0c0c0; }
|
||||||
|
#attract-table tr.rank-3 td { color: #cd7f32; }
|
||||||
|
.attract-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #444;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
animation: flicker 1.2s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game-over overlay ── */
|
||||||
|
#overlay {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.90);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
#overlay.active { display: flex; }
|
||||||
|
|
||||||
|
#overlay h1 {
|
||||||
|
font-size: 44px;
|
||||||
|
color: #ff4444;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-shadow: 0 0 30px #ff4444;
|
||||||
|
animation: flicker 1s infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes flicker {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0.75; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#final-score {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #00ff88;
|
||||||
|
text-shadow: 0 0 8px #00ff88;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
#new-record {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #ffcc00;
|
||||||
|
text-shadow: 0 0 12px #ffcc00;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
animation: flicker 0.6s infinite alternate;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#new-record.visible { display: block; }
|
||||||
|
|
||||||
|
/* ── Name form ── */
|
||||||
|
#name-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
#player-name {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #00ff88;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
#player-name:focus { border-color: #00ffcc; }
|
||||||
|
#submit-score-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
background: #00ff88;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#submit-score-btn:hover { background: #00cc66; }
|
||||||
|
|
||||||
|
/* ── Leaderboard ── */
|
||||||
|
#leaderboard {
|
||||||
|
width: 340px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
#leaderboard h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: #ffcc00;
|
||||||
|
text-shadow: 0 0 8px #ffcc00;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
#leaderboard table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
#leaderboard th {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
#leaderboard td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #ccc;
|
||||||
|
border-bottom: 1px solid #1a1a2e;
|
||||||
|
}
|
||||||
|
#leaderboard tr.rank-1 td { color: #ffd700; }
|
||||||
|
#leaderboard tr.rank-2 td { color: #c0c0c0; }
|
||||||
|
#leaderboard tr.rank-3 td { color: #cd7f32; }
|
||||||
|
#leaderboard tr.highlight td {
|
||||||
|
color: #00ff88;
|
||||||
|
text-shadow: 0 0 6px #00ff88;
|
||||||
|
background: rgba(0, 255, 136, 0.06);
|
||||||
|
}
|
||||||
|
.rank-num { width: 32px; text-align: center; color: #555; }
|
||||||
|
|
||||||
|
#restart-btn {
|
||||||
|
padding: 14px 40px;
|
||||||
|
background: #00ff88;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#restart-btn:hover { background: #00cc66; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="gameCanvas"></canvas>
|
||||||
|
|
||||||
|
<div id="cystinstein-area">
|
||||||
|
<div id="cystinstein-label">CYSTINSTEIN</div>
|
||||||
|
<canvas id="cystinstein-canvas" width="130" height="130"></canvas>
|
||||||
|
<div id="docks-count">0 / 6 docks</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="score">Score: 0</div>
|
||||||
|
<div id="highscore">Best: —</div>
|
||||||
|
|
||||||
|
<div id="intro">
|
||||||
|
<h1>SYS DIGGER</h1>
|
||||||
|
<div class="subtitle">Protect the atom</div>
|
||||||
|
|
||||||
|
<div class="instructions">
|
||||||
|
<div class="instruction-row">
|
||||||
|
<div class="instruction-icon">⚛</div>
|
||||||
|
<div class="instruction-text">
|
||||||
|
A molecule sits at the center of the screen with <strong>anchor points</strong> around it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="instruction-row">
|
||||||
|
<div class="instruction-icon">💊</div>
|
||||||
|
<div class="instruction-text">
|
||||||
|
<strong>Click the glowing red anchor</strong> when an atom approaches to block the connection.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="instruction-row">
|
||||||
|
<div class="instruction-icon">🧫</div>
|
||||||
|
<div class="instruction-text">
|
||||||
|
Each missed dock grows the <strong>Cystinstein</strong> in the top-left.
|
||||||
|
Allow <strong>${MAX_DOCKS} docks</strong> and it's fully formed — game over.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="start-btn" onclick="startGame()">START</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="attract" class="hidden">
|
||||||
|
<h2>HIGH SCORES</h2>
|
||||||
|
<div class="attract-game-title">SYS DIGGER</div>
|
||||||
|
<table id="attract-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="rank-num">#</th>
|
||||||
|
<th style="text-align:left">Name</th>
|
||||||
|
<th style="text-align:right">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="attract-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="attract-hint">— Press any key to continue —</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="overlay">
|
||||||
|
<h1>CYSTINSTEIN FORMED!</h1>
|
||||||
|
<p id="final-score"></p>
|
||||||
|
<p id="new-record">★ NEW HIGH SCORE ★</p>
|
||||||
|
|
||||||
|
<div id="name-form">
|
||||||
|
<input id="player-name" type="text" maxlength="20" placeholder="Your name" autocomplete="off" />
|
||||||
|
<button id="submit-score-btn" onclick="submitScore()">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="leaderboard">
|
||||||
|
<h2>LEADERBOARD</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="rank-num">#</th>
|
||||||
|
<th style="text-align:left">Name</th>
|
||||||
|
<th style="text-align:right">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="leaderboard-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="restart-btn" onclick="restartGame()">Play Again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="constants.js"></script>
|
||||||
|
<script src="atom.js"></script>
|
||||||
|
<script src="render.js"></script>
|
||||||
|
<script src="game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
sys_digger/readme.md
Normal file
8
sys_digger/readme.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Game Sys Digger
|
||||||
|
|
||||||
|
We are going to develop an html game called Sys Digger. The game will be loosly based on the game whac-a-mole.
|
||||||
|
In this game the user has to protect an atom from growing to a Cystinstein. For this the user has to hit on the end of the atom where other atom's can connect to form a molekul.
|
||||||
|
The user has to prevent this so the atom doesn't grow into a Cystinstein. When to many connections are made the user looses the game.
|
||||||
|
|
||||||
|
In the center of the game the molekul is shown. This molekul has a set of anker points where other morlekuls can dock onto to create the Cystinstein. The user has to prevent the other modules from docking by hitting the anker point. For every successfull prevented dock the user gets a point.
|
||||||
|
In the left upper corner a small area where the growing of the Cystinstein is shown. When the user is to late to preent the dock the Cystinstein in this area grows. When the user is uanble to prevent 8 docks the user looses the game and Cystinstein is at his max size.
|
||||||
210
sys_digger/render.js
Normal file
210
sys_digger/render.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
function burst(x, y, color) {
|
||||||
|
for (let i = 0; i < BURST_COUNT; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2;
|
||||||
|
const s = BURST_SPEED_MIN + Math.random() * (BURST_SPEED_MAX - BURST_SPEED_MIN);
|
||||||
|
particles.push({
|
||||||
|
x, y,
|
||||||
|
vx: Math.cos(a) * s, vy: Math.sin(a) * s,
|
||||||
|
life: 1,
|
||||||
|
decay: PARTICLE_DECAY_MIN + Math.random() * (PARTICLE_DECAY_MAX - PARTICLE_DECAY_MIN),
|
||||||
|
r: PARTICLE_RADIUS_MIN + Math.random() * (PARTICLE_RADIUS_MAX - PARTICLE_RADIUS_MIN),
|
||||||
|
color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBackground() {
|
||||||
|
ctx.fillStyle = '#0a0a1a';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
stars.forEach(s => {
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${s.a})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCentralMolecule() {
|
||||||
|
const t = Date.now() / 1000;
|
||||||
|
|
||||||
|
const amb = ctx.createRadialGradient(CX, CY, 10, CX, CY, ANCHOR_RADIUS + 50);
|
||||||
|
amb.addColorStop(0, 'rgba(0,180,255,0.12)');
|
||||||
|
amb.addColorStop(1, 'transparent');
|
||||||
|
ctx.fillStyle = amb;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX, CY, ANCHOR_RADIUS + 50, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
anchorPoints.forEach(ap => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(CX, CY);
|
||||||
|
ctx.lineTo(ap.x, ap.y);
|
||||||
|
ctx.strokeStyle = ap.active ? 'rgba(255,80,80,0.45)' : 'rgba(0,200,255,0.18)';
|
||||||
|
ctx.lineWidth = ap.active ? 2 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ng = ctx.createRadialGradient(CX, CY, 0, CX, CY, NUCLEUS_RADIUS);
|
||||||
|
ng.addColorStop(0, '#00ffcc');
|
||||||
|
ng.addColorStop(0.55, '#0088ff');
|
||||||
|
ng.addColorStop(1, 'rgba(0,80,220,0.3)');
|
||||||
|
ctx.fillStyle = ng;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX, CY, NUCLEUS_RADIUS, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
const pulse = Math.sin(t * 2) * 0.3 + 0.7;
|
||||||
|
ctx.strokeStyle = `rgba(0,255,200,${pulse * 0.6})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX, CY, NUCLEUS_RADIUS + Math.sin(t * 2) * 5, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX - 16, CY - 16, 8, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
for (let i = 0; i < NUCLEUS_ORBITS; i++) {
|
||||||
|
const orbitAngle = t * (0.45 + i * 0.3) + (i * Math.PI * 2 / 3);
|
||||||
|
const rx = 72 + i * 10;
|
||||||
|
const ry = 28 + i * 8;
|
||||||
|
const tilt = i * Math.PI / 3;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(CX, CY);
|
||||||
|
ctx.rotate(tilt);
|
||||||
|
ctx.strokeStyle = 'rgba(0,200,255,0.13)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#88eeff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(Math.cos(orbitAngle) * rx, Math.sin(orbitAngle) * ry, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAnchorPoints() {
|
||||||
|
const t = Date.now() / 1000;
|
||||||
|
anchorPoints.forEach(ap => {
|
||||||
|
const pulse = Math.sin(t * 3 + ap.pulsePhase) * 0.5 + 0.5;
|
||||||
|
|
||||||
|
if (ap.blockTimer > 0) {
|
||||||
|
ap.blockTimer--;
|
||||||
|
ctx.strokeStyle = `rgba(0,255,100,${ap.blockTimer / BLOCK_TIMER_FRAMES})`;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ap.x, ap.y, 20 + (BLOCK_TIMER_FRAMES - ap.blockTimer), 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(0,255,100,0.55)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ap.x, ap.y, 13, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else if (ap.active) {
|
||||||
|
ctx.strokeStyle = `rgba(255,70,70,${0.5 + pulse * 0.5})`;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ap.x, ap.y, 18 + pulse * 6, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(255,70,70,0.35)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ap.x, ap.y, 15, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,200,200,0.85)';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ap.x - 9, ap.y); ctx.lineTo(ap.x + 9, ap.y);
|
||||||
|
ctx.moveTo(ap.x, ap.y - 9); ctx.lineTo(ap.x, ap.y + 9);
|
||||||
|
ctx.stroke();
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = `rgba(0,200,255,${0.18 + pulse * 0.28})`;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ap.x, ap.y, 13, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(0,200,255,0.08)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ap.x, ap.y, 13, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles() {
|
||||||
|
particles = particles.filter(p => p.life > 0);
|
||||||
|
particles.forEach(p => {
|
||||||
|
p.x += p.vx; p.y += p.vy;
|
||||||
|
p.vx *= 0.94; p.vy *= 0.94;
|
||||||
|
p.life -= p.decay;
|
||||||
|
ctx.globalAlpha = p.life;
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawClickEffects() {
|
||||||
|
clickEffects = clickEffects.filter(e => e.life > 0);
|
||||||
|
clickEffects.forEach(e => {
|
||||||
|
e.radius += 3;
|
||||||
|
e.life -= 0.06;
|
||||||
|
ctx.globalAlpha = e.life;
|
||||||
|
ctx.strokeStyle = '#00ff88';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(e.x, e.y, e.radius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCystinstein() {
|
||||||
|
cysCtx.clearRect(0, 0, cysCanvas.width, cysCanvas.height);
|
||||||
|
|
||||||
|
cysCtx.fillStyle = '#00aaff';
|
||||||
|
cysCtx.shadowColor = '#00aaff';
|
||||||
|
cysCtx.shadowBlur = docks > 0 ? 6 : 0;
|
||||||
|
cysCtx.beginPath();
|
||||||
|
cysCtx.arc(CYS_CENTER_X, CYS_CENTER_Y, CYS_CORE_RADIUS, 0, Math.PI * 2);
|
||||||
|
cysCtx.fill();
|
||||||
|
cysCtx.shadowBlur = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < docks && i < MAX_DOCKS; i++) {
|
||||||
|
const angle = (i / MAX_DOCKS) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const ax = CYS_CENTER_X + Math.cos(angle) * CYS_DOCK_RADIUS;
|
||||||
|
const ay = CYS_CENTER_Y + Math.sin(angle) * CYS_DOCK_RADIUS;
|
||||||
|
const danger = i / (MAX_DOCKS - 1);
|
||||||
|
|
||||||
|
cysCtx.strokeStyle = `rgba(255,${Math.floor(150 - danger * 150)},50,0.8)`;
|
||||||
|
cysCtx.lineWidth = 2;
|
||||||
|
cysCtx.beginPath();
|
||||||
|
cysCtx.moveTo(CYS_CENTER_X, CYS_CENTER_Y);
|
||||||
|
cysCtx.lineTo(ax, ay);
|
||||||
|
cysCtx.stroke();
|
||||||
|
|
||||||
|
cysCtx.fillStyle = `rgb(${Math.floor(200 + danger * 55)},${Math.floor(100 - danger * 100)},50)`;
|
||||||
|
cysCtx.shadowColor = '#ff4444';
|
||||||
|
cysCtx.shadowBlur = danger > 0.7 ? 8 : 0;
|
||||||
|
cysCtx.beginPath();
|
||||||
|
cysCtx.arc(ax, ay, CYS_ATOM_RADIUS, 0, Math.PI * 2);
|
||||||
|
cysCtx.fill();
|
||||||
|
cysCtx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docks >= MAX_DOCKS) {
|
||||||
|
cysCtx.strokeStyle = 'rgba(255,0,0,0.9)';
|
||||||
|
cysCtx.lineWidth = 3;
|
||||||
|
cysCtx.shadowColor = '#ff0000';
|
||||||
|
cysCtx.shadowBlur = 15;
|
||||||
|
cysCtx.beginPath();
|
||||||
|
cysCtx.arc(CYS_CENTER_X, CYS_CENTER_Y, CYS_RING_RADIUS, 0, Math.PI * 2);
|
||||||
|
cysCtx.stroke();
|
||||||
|
cysCtx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
thiola-pong/constants.js
Normal file
60
thiola-pong/constants.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const PADDLE_W = 12;
|
||||||
|
const PADDLE_H = 90;
|
||||||
|
const PADDLE_MARGIN = 42;
|
||||||
|
const BALL_R = 7;
|
||||||
|
const BASE_SPEED = 3.5;
|
||||||
|
const WIN_SCORE = 5;
|
||||||
|
const MAX_LEVEL = 3;
|
||||||
|
const WALL_WIDTH = 32;
|
||||||
|
|
||||||
|
const PADDLE_SPIN_FACTOR = 0.5;
|
||||||
|
const BALL_RESUME_DELAY = 800; // ms pause after dismissing a goal message
|
||||||
|
|
||||||
|
const LEVEL_COVERAGE = [0, 0, 40, 70];
|
||||||
|
const LEVEL_CPU_SPEED = [0, 1.6, 3.0, 3.8];
|
||||||
|
|
||||||
|
const playerGoalMessages = [
|
||||||
|
{ icon: '🔬', title: 'Steinanalyse rettet Leben',
|
||||||
|
text: 'Jeder Nierenstein sollte analysiert werden. Nur so kann ein <strong class="thl">Cystinstein</strong> sicher identifiziert werden.' },
|
||||||
|
{ icon: '🎯', title: 'Früherkennung ist der Schlüssel',
|
||||||
|
text: 'Je früher Cystinurie erkannt wird, desto besser die Prognose. Die <strong>Steinanalyse</strong> ist der erste Schritt zur Diagnose.' },
|
||||||
|
{ icon: '💊', title: 'Thiola senkt das Cystinniveau',
|
||||||
|
text: '<strong class="thl">Thiola (Tiopronin)</strong> senkt die Cystinkonzentration im Urin und <strong>verhindert die Neubildung</strong> von Steinen.' },
|
||||||
|
{ icon: '🛡️', title: 'Schutz durch konsequente Therapie',
|
||||||
|
text: 'Unter konsequenter <strong class="thl">Thiola</strong>-Therapie kann die Steinbildungsrate <strong>signifikant reduziert</strong> werden.' },
|
||||||
|
{ icon: '✅', title: 'Evidenzbasierter Therapiestandard',
|
||||||
|
text: '<strong class="thl">Tiopronin (Thiola)</strong> ist der <strong>Goldstandard</strong> in der medikamentösen Prävention von Cystinsteinen.' },
|
||||||
|
{ icon: '🔁', title: 'Rezidivprophylaxe mit Thiola',
|
||||||
|
text: 'Ohne spezifische Therapie liegt die <strong class="warn">Rezidivrate bei Cystinsteinen bei bis zu 60%</strong>. <strong class="thl">Thiola</strong> kann das verhindern.' },
|
||||||
|
{ icon: '📋', title: 'Leitliniengerecht handeln',
|
||||||
|
text: 'Die <strong>urologischen Leitlinien</strong> empfehlen eine konsequente <strong>Steinanalyse</strong> als Basis jeder Metaphylaxe.' },
|
||||||
|
{ icon: '🏆', title: 'Therapieerfolg messbar',
|
||||||
|
text: 'Unter <strong class="thl">Thiola</strong>-Therapie kann die Cystinausscheidung im Urin kontrolliert und der <strong>Therapieerfolg laborchemisch überwacht</strong> werden.' },
|
||||||
|
{ icon: '💎', title: 'Analyse schafft Klarheit',
|
||||||
|
text: 'Cystinsteine machen nur <strong>1–2% aller Nierensteine</strong> aus — ohne Analyse bleiben sie <strong class="warn">unerkannt</strong>.' },
|
||||||
|
{ icon: '🔑', title: 'Der Schlüssel zur Prävention',
|
||||||
|
text: '<strong>Steinanalyse → Diagnose → <span class="thl">Thiola</span></strong> — dieser Dreiklang schützt Patienten vor neuen Steinen.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cpuGoalMessages = [
|
||||||
|
{ icon: '⚡', title: 'Kolikschmerzen — unerträglich',
|
||||||
|
text: 'Nierenkoliken gehören zu den <strong class="warn">stärksten Schmerzen</strong>, die ein Mensch erleben kann. Cystinstein-Patienten erleben das <strong>immer wieder</strong>.' },
|
||||||
|
{ icon: '🔄', title: 'Rezidive ohne Ende',
|
||||||
|
text: 'Ohne gezielte Therapie bilden sich Cystinsteine <strong class="warn">immer wieder neu</strong>. Bis zu <strong class="warn">60% Rezidivrate</strong> — ein Teufelskreis für Betroffene.' },
|
||||||
|
{ icon: '🏥', title: 'Wiederholte Operationen',
|
||||||
|
text: 'Viele Cystinurie-Patienten müssen <strong class="warn">mehrfach operiert</strong> werden. Jeder Eingriff belastet die Niere zusätzlich.' },
|
||||||
|
{ icon: '⏳', title: 'Diagnose oft viel zu spät',
|
||||||
|
text: 'Ohne Steinanalyse dauert es oft <strong class="warn">Jahre bis zur richtigen Diagnose</strong>. Wertvolle Zeit, in der die Niere Schaden nimmt.' },
|
||||||
|
{ icon: '🫘', title: 'Nierenfunktion in Gefahr',
|
||||||
|
text: 'Wiederholte Steinereignisse können zum <strong class="warn">Verlust der Nierenfunktion</strong> führen — besonders bei <strong>jungen Patienten</strong>.' },
|
||||||
|
{ icon: '👶', title: 'Junge Patienten betroffen',
|
||||||
|
text: 'Cystinurie manifestiert sich oft schon im <strong>Kindes- und Jugendalter</strong>. Betroffene leiden ihr <strong class="warn">ganzes Leben</strong>.' },
|
||||||
|
{ icon: '🧬', title: 'Genetisch bedingt — lebenslang',
|
||||||
|
text: 'Cystinurie ist eine <strong>autosomal-rezessiv vererbte</strong> Erkrankung. Die Steinbildung hört <strong class="warn">ohne Therapie nie auf</strong>.' },
|
||||||
|
{ icon: '😔', title: 'Lebensqualität massiv eingeschränkt',
|
||||||
|
text: 'Ständige Angst vor der nächsten Kolik, Krankenhausaufenthalte, OPs — die <strong class="warn">Lebensqualität sinkt drastisch</strong>.' },
|
||||||
|
{ icon: '❓', title: 'Fehlende Steinanalyse = Blindflug',
|
||||||
|
text: 'Ohne Steinanalyse wird <strong class="warn">symptomatisch statt kausal</strong> behandelt. Die wahre Ursache bleibt im Dunkeln.' },
|
||||||
|
{ icon: '📉', title: 'Kosten für das Gesundheitssystem',
|
||||||
|
text: 'Wiederholte Notaufnahmen, OPs und Krankheitstage — <strong class="warn">unerkannte Cystinurie</strong> verursacht immense Folgekosten.' },
|
||||||
|
];
|
||||||
456
thiola-pong/game.js
Normal file
456
thiola-pong/game.js
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
(() => {
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
|
||||||
|
// DOM
|
||||||
|
const overlay = document.getElementById('overlay');
|
||||||
|
const startBtnCpu = document.getElementById('start-btn-cpu');
|
||||||
|
const startBtnPvp = document.getElementById('start-btn-pvp');
|
||||||
|
const hudLevel = document.getElementById('hud-level');
|
||||||
|
const hudShield = document.getElementById('hud-shield');
|
||||||
|
const hudPScore = document.getElementById('hud-pscore');
|
||||||
|
const hudCScore = document.getElementById('hud-cscore');
|
||||||
|
const popup = document.getElementById('popup');
|
||||||
|
const popupTitle = document.getElementById('popup-title');
|
||||||
|
const popupContent = document.getElementById('popup-content');
|
||||||
|
const popupSub = document.getElementById('popup-sub');
|
||||||
|
const popupBtn = document.getElementById('popup-btn');
|
||||||
|
const goalFlash = document.getElementById('goal-flash');
|
||||||
|
const flashScore = document.getElementById('flash-score');
|
||||||
|
const flashIcon = document.getElementById('flash-icon');
|
||||||
|
const flashTitle = document.getElementById('flash-title');
|
||||||
|
const flashText = document.getElementById('flash-text');
|
||||||
|
|
||||||
|
// ─── Game state ───
|
||||||
|
let level = 1, playerScore = 0, cpuScore = 0;
|
||||||
|
let running = false, paused = false;
|
||||||
|
let gameMode = 'cpu'; // 'cpu' or 'pvp'
|
||||||
|
|
||||||
|
let ballX, ballY, ballVX, ballVY;
|
||||||
|
let playerY = H/2 - PADDLE_H/2, cpuY = H/2 - PADDLE_H/2;
|
||||||
|
let cpuSpeed = 1.6;
|
||||||
|
let coveragePercent = 0, gapTop, gapBottom;
|
||||||
|
let keyW = false, keyS = false;
|
||||||
|
let keyUp = false, keyDown = false, mouseY = null;
|
||||||
|
let particles = [], trails = [];
|
||||||
|
let glowPhase = 0;
|
||||||
|
|
||||||
|
let playerMsgIndex = 0, cpuMsgIndex = 0;
|
||||||
|
|
||||||
|
// ─── Level config ───
|
||||||
|
function getCoverage(lvl) { const v = LEVEL_COVERAGE[lvl]; return v !== undefined ? v : 70; }
|
||||||
|
function getCpuSpeed(lvl) { const v = LEVEL_CPU_SPEED[lvl]; return v !== undefined ? v : 3.8; }
|
||||||
|
|
||||||
|
// ─── Helpers ───
|
||||||
|
function calcGap() {
|
||||||
|
const openH = H * ((100 - coveragePercent) / 100);
|
||||||
|
gapTop = (H - openH) / 2;
|
||||||
|
gapBottom = gapTop + openH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBall(dir) {
|
||||||
|
ballX = W / 2; ballY = H / 2;
|
||||||
|
const angle = (Math.random() * 0.7 - 0.35);
|
||||||
|
const speed = BASE_SPEED + level * 0.2;
|
||||||
|
ballVX = speed * dir;
|
||||||
|
ballVY = speed * Math.sin(angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnParticles(x, y, color, count) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
particles.push({ x, y, vx: (Math.random()-.5)*7, vy: (Math.random()-.5)*7, life: 1, decay: 0.02+Math.random()*0.03, r: 2+Math.random()*3, color });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHUD() {
|
||||||
|
hudLevel.textContent = level;
|
||||||
|
hudShield.textContent = Math.round(coveragePercent);
|
||||||
|
hudPScore.textContent = playerScore;
|
||||||
|
hudCScore.textContent = cpuScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLevel() {
|
||||||
|
playerScore = 0; cpuScore = 0;
|
||||||
|
coveragePercent = getCoverage(level);
|
||||||
|
cpuSpeed = getCpuSpeed(level);
|
||||||
|
calcGap(); resetBall(1); updateHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Score a goal ───
|
||||||
|
function scoreGoal(isPlayerGoal) {
|
||||||
|
if (isPlayerGoal) playerScore++;
|
||||||
|
else cpuScore++;
|
||||||
|
updateHUD();
|
||||||
|
void hudPScore.offsetHeight;
|
||||||
|
showGoalFlash(isPlayerGoal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Goal flash (edu per goal) ───
|
||||||
|
function showGoalFlash(isPlayerGoal) {
|
||||||
|
paused = true;
|
||||||
|
|
||||||
|
let msg;
|
||||||
|
if (isPlayerGoal) {
|
||||||
|
msg = playerGoalMessages[playerMsgIndex % playerGoalMessages.length];
|
||||||
|
playerMsgIndex++;
|
||||||
|
} else {
|
||||||
|
msg = cpuGoalMessages[cpuMsgIndex % cpuGoalMessages.length];
|
||||||
|
cpuMsgIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
flashScore.textContent = `${playerScore} : ${cpuScore}`;
|
||||||
|
flashIcon.textContent = msg.icon;
|
||||||
|
flashTitle.textContent = msg.title;
|
||||||
|
flashText.innerHTML = msg.text;
|
||||||
|
void goalFlash.offsetHeight;
|
||||||
|
goalFlash.className = isPlayerGoal ? 'show player-goal' : 'show cpu-goal';
|
||||||
|
|
||||||
|
function cont(e) {
|
||||||
|
if (e && e.type === 'keydown') {
|
||||||
|
const k = e.key;
|
||||||
|
if (k === 'ArrowUp' || k === 'ArrowDown' || k === 'w' || k === 'W' || k === 's' || k === 'S') return;
|
||||||
|
}
|
||||||
|
goalFlash.className = '';
|
||||||
|
document.removeEventListener('keydown', cont);
|
||||||
|
goalFlash.removeEventListener('click', cont);
|
||||||
|
if (playerScore >= WIN_SCORE) { advanceLevel(); }
|
||||||
|
else if (cpuScore >= WIN_SCORE) { showGameOver(); }
|
||||||
|
else { resetBall(isPlayerGoal ? -1 : 1); setTimeout(() => { paused = false; }, BALL_RESUME_DELAY); }
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('keydown', cont);
|
||||||
|
goalFlash.addEventListener('click', cont);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Popup helper ───
|
||||||
|
function showPopup(title, html, sub, btnText, btnClass, onContinue) {
|
||||||
|
popupTitle.textContent = title;
|
||||||
|
popupContent.innerHTML = html;
|
||||||
|
popupSub.textContent = sub || '';
|
||||||
|
popupBtn.textContent = btnText;
|
||||||
|
popupBtn.className = btnClass || 'game-btn';
|
||||||
|
popup.classList.add('show');
|
||||||
|
paused = true;
|
||||||
|
popupBtn.onclick = () => {
|
||||||
|
popup.classList.remove('show');
|
||||||
|
paused = false;
|
||||||
|
popupBtn.blur();
|
||||||
|
if (onContinue) onContinue();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Start screen ───
|
||||||
|
function showStartScreen() {
|
||||||
|
running = false;
|
||||||
|
paused = false;
|
||||||
|
level = 1;
|
||||||
|
coveragePercent = 0;
|
||||||
|
popup.classList.remove('show');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Level transitions ───
|
||||||
|
function advanceLevel() {
|
||||||
|
if (level === 1) {
|
||||||
|
level = 2;
|
||||||
|
showPopup('LEVEL 1 GESCHAFFT!',
|
||||||
|
`<div class="edu-card">
|
||||||
|
<span class="edu-icon">🛡️</span>
|
||||||
|
<div class="edu-title">Verstärkung kommt: Thiola!</div>
|
||||||
|
<div class="edu-text">
|
||||||
|
Gut gespielt! Doch im nächsten Level wird der Gegner stärker.<br><br>
|
||||||
|
Zum Glück bekommst du jetzt Unterstützung von
|
||||||
|
<strong class="thl">✦ THIOLA ✦</strong><br><br>
|
||||||
|
Thiola-Wände schützen Teile deines Tors und blockieren eingehende Bälle —
|
||||||
|
genau wie <strong class="thl">Thiola (Tiopronin)</strong> die Neubildung von Cystinsteinen verhindert.
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
'Level 2 — 40% Thiola-Schutz',
|
||||||
|
'MIT THIOLA WEITERSPIELEN', 'game-btn thiola',
|
||||||
|
() => startLevel()
|
||||||
|
);
|
||||||
|
} else if (level === 2) {
|
||||||
|
level = 3;
|
||||||
|
showPopup('LEVEL 2 GESCHAFFT!',
|
||||||
|
`<div class="edu-card">
|
||||||
|
<span class="edu-icon">💊</span>
|
||||||
|
<div class="edu-title">Thiola verstärkt den Schutz!</div>
|
||||||
|
<div class="edu-text">
|
||||||
|
Der Gegner wird noch aggressiver — aber <strong class="thl">Thiola</strong> auch!<br><br>
|
||||||
|
Im finalen Level schützt Thiola <strong>70%</strong> deines Tors.<br>
|
||||||
|
<strong>Konsequente Therapie = maximaler Schutz.</strong>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
'Finales Level — 70% Thiola-Schutz',
|
||||||
|
'THIOLA-SCHUTZ MAXIMIEREN', 'game-btn thiola',
|
||||||
|
() => startLevel()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showFinalScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFinalScreen() {
|
||||||
|
showPopup('🏆 GEWONNEN!',
|
||||||
|
`<div class="final-summary">
|
||||||
|
<div class="final-title">ALLE 3 LEVEL GEMEISTERT!</div>
|
||||||
|
<div class="final-text">
|
||||||
|
Du hast erlebt, wie <strong style="color:#55efc4">Thiola</strong> dein Tor Schritt für Schritt geschützt hat —
|
||||||
|
genau so schützt <strong style="color:#55efc4">Thiola (Tiopronin)</strong> Cystinurie-Patienten
|
||||||
|
vor der Neubildung von Cystinsteinen.
|
||||||
|
</div>
|
||||||
|
<div class="pathway">
|
||||||
|
<span class="step">🔬 Steinanalyse</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="step">🎯 Diagnose</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="step" style="background:rgba(0,184,148,.2); border-color:rgba(0,184,148,.5)">💊 Thiola</span>
|
||||||
|
</div>
|
||||||
|
<div class="final-text" style="font-size:10px; color:#aaa; margin-top:6px">
|
||||||
|
<strong>Jeder Stein verdient eine Analyse.</strong><br>
|
||||||
|
Denn nur wer Cystinsteine erkennt, kann sie gezielt verhindern.
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
'', 'NOCHMAL SPIELEN', 'game-btn',
|
||||||
|
() => showStartScreen()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGameOver() {
|
||||||
|
showPopup(`LEVEL ${level} VERLOREN`,
|
||||||
|
`<div class="edu-card">
|
||||||
|
<span class="edu-icon">⚡</span>
|
||||||
|
<div class="edu-title">Nicht aufgeben!</div>
|
||||||
|
<div class="edu-text">
|
||||||
|
Auch bei Cystinurie gilt: <strong>konsequente Therapie</strong> ist der Schlüssel.<br>
|
||||||
|
Versuche es erneut — mit <strong class="thl">Thiola</strong> an deiner Seite!
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
'Zurück zum Start', 'NOCHMAL VERSUCHEN', 'game-btn',
|
||||||
|
() => showStartScreen()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ───
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'ArrowUp') keyUp = true;
|
||||||
|
if (e.key === 'ArrowDown') keyDown = true;
|
||||||
|
if (e.key === 'w' || e.key === 'W') keyW = true;
|
||||||
|
if (e.key === 's' || e.key === 'S') keyS = true;
|
||||||
|
});
|
||||||
|
document.addEventListener('keyup', e => {
|
||||||
|
if (e.key === 'ArrowUp') keyUp = false;
|
||||||
|
if (e.key === 'ArrowDown') keyDown = false;
|
||||||
|
if (e.key === 'w' || e.key === 'W') keyW = false;
|
||||||
|
if (e.key === 's' || e.key === 'S') keyS = false;
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', e => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouseY = (e.clientY - rect.top) * (H / rect.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Update ───
|
||||||
|
function update() {
|
||||||
|
if (!running || paused) return;
|
||||||
|
glowPhase += 0.03;
|
||||||
|
|
||||||
|
const prevPlayerY = playerY;
|
||||||
|
const prevCpuY = cpuY;
|
||||||
|
|
||||||
|
// Player 1 (left) — PvP: W/S only; CPU mode: mouse OR W/S
|
||||||
|
if (gameMode === 'pvp') {
|
||||||
|
if (keyW) playerY -= 6;
|
||||||
|
if (keyS) playerY += 6;
|
||||||
|
} else {
|
||||||
|
if (mouseY !== null) {
|
||||||
|
playerY += (mouseY - PADDLE_H/2 - playerY) * 0.15;
|
||||||
|
} else {
|
||||||
|
if (keyUp || keyW) playerY -= 6;
|
||||||
|
if (keyDown || keyS) playerY += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playerY = Math.max(0, Math.min(H - PADDLE_H, playerY));
|
||||||
|
|
||||||
|
// Player 2 / CPU (right)
|
||||||
|
if (gameMode === 'pvp') {
|
||||||
|
if (keyUp) cpuY -= 6;
|
||||||
|
if (keyDown) cpuY += 6;
|
||||||
|
} else {
|
||||||
|
const diff = ballY - (cpuY + PADDLE_H/2);
|
||||||
|
if (Math.abs(diff) > 10) cpuY += Math.sign(diff) * Math.min(cpuSpeed, Math.abs(diff) * 0.07);
|
||||||
|
}
|
||||||
|
cpuY = Math.max(0, Math.min(H - PADDLE_H, cpuY));
|
||||||
|
|
||||||
|
const playerVY = playerY - prevPlayerY;
|
||||||
|
const cpuVY = cpuY - prevCpuY;
|
||||||
|
|
||||||
|
// Ball movement
|
||||||
|
ballX += ballVX; ballY += ballVY;
|
||||||
|
if (ballY - BALL_R <= 0) { ballY = BALL_R; ballVY = Math.abs(ballVY); }
|
||||||
|
if (ballY + BALL_R >= H) { ballY = H - BALL_R; ballVY = -Math.abs(ballVY); }
|
||||||
|
trails.push({ x: ballX, y: ballY, life: 1 });
|
||||||
|
|
||||||
|
// Right (CPU) side
|
||||||
|
if (ballX + BALL_R >= W - PADDLE_MARGIN - PADDLE_W) {
|
||||||
|
if (ballX + BALL_R <= W - PADDLE_MARGIN + PADDLE_W && ballY >= cpuY && ballY <= cpuY + PADDLE_H) {
|
||||||
|
ballVX = -Math.abs(ballVX) * 1.03;
|
||||||
|
ballVY += ((ballY - cpuY)/PADDLE_H - 0.5) * 3 + cpuVY * PADDLE_SPIN_FACTOR;
|
||||||
|
ballX = W - PADDLE_MARGIN - PADDLE_W - BALL_R;
|
||||||
|
spawnParticles(ballX, ballY, '#ff6b35', 8);
|
||||||
|
} else if (ballX > W + BALL_R) {
|
||||||
|
spawnParticles(W, ballY, '#4ecdc4', 20);
|
||||||
|
scoreGoal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left (Player) side + Thiola wall
|
||||||
|
if (ballX - BALL_R <= PADDLE_MARGIN + PADDLE_W) {
|
||||||
|
if (ballX - BALL_R >= PADDLE_MARGIN - PADDLE_W && ballY >= playerY && ballY <= playerY + PADDLE_H) {
|
||||||
|
ballVX = Math.abs(ballVX) * 1.03;
|
||||||
|
ballVY += ((ballY - playerY)/PADDLE_H - 0.5) * 3 + playerVY * PADDLE_SPIN_FACTOR;
|
||||||
|
ballX = PADDLE_MARGIN + PADDLE_W + BALL_R;
|
||||||
|
spawnParticles(ballX, ballY, '#4ecdc4', 8);
|
||||||
|
} else if (ballX - BALL_R <= WALL_WIDTH && coveragePercent > 0) {
|
||||||
|
if (ballY < gapTop || ballY > gapBottom) {
|
||||||
|
ballVX = Math.abs(ballVX);
|
||||||
|
ballX = WALL_WIDTH + BALL_R;
|
||||||
|
spawnParticles(WALL_WIDTH, ballY, '#55efc4', 15);
|
||||||
|
} else if (ballX - BALL_R <= 0) {
|
||||||
|
spawnParticles(0, ballY, '#ff6b35', 20);
|
||||||
|
scoreGoal(false);
|
||||||
|
}
|
||||||
|
} else if (ballX - BALL_R <= 0 && coveragePercent === 0) {
|
||||||
|
spawnParticles(0, ballY, '#ff6b35', 20);
|
||||||
|
scoreGoal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp speed
|
||||||
|
const spd = Math.sqrt(ballVX*ballVX + ballVY*ballVY);
|
||||||
|
if (spd > 12) { ballVX = (ballVX/spd)*12; ballVY = (ballVY/spd)*12; }
|
||||||
|
|
||||||
|
particles = particles.filter(p => { p.x+=p.vx; p.y+=p.vy; p.life-=p.decay; p.vx*=.96; p.vy*=.96; return p.life>0; });
|
||||||
|
trails = trails.filter(t => { t.life -= 0.06; return t.life > 0; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Draw ───
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Background grid
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,.02)'; ctx.lineWidth = 1;
|
||||||
|
for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
|
||||||
|
for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
|
||||||
|
|
||||||
|
// Center line
|
||||||
|
ctx.setLineDash([8,12]); ctx.strokeStyle='rgba(255,255,255,.08)'; ctx.lineWidth=2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(W/2,0); ctx.lineTo(W/2,H); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
|
||||||
|
calcGap();
|
||||||
|
|
||||||
|
// Thiola walls
|
||||||
|
if (coveragePercent > 0) {
|
||||||
|
const pa = 0.7 + 0.3 * Math.sin(glowPhase);
|
||||||
|
if (gapTop > 0) { drawThiolaWall(0, 0, WALL_WIDTH, gapTop, pa); drawThiolaText(WALL_WIDTH/2, gapTop/2, gapTop); }
|
||||||
|
if (gapBottom < H) { drawThiolaWall(0, gapBottom, WALL_WIDTH, H-gapBottom, pa); drawThiolaText(WALL_WIDTH/2, gapBottom+(H-gapBottom)/2, H-gapBottom); }
|
||||||
|
ctx.fillStyle='#fff'; ctx.shadowColor='#55efc4'; ctx.shadowBlur=14;
|
||||||
|
ctx.fillRect(0, gapTop-3, WALL_WIDTH+6, 3);
|
||||||
|
ctx.fillRect(0, gapBottom, WALL_WIDTH+6, 3);
|
||||||
|
ctx.shadowBlur=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paddles
|
||||||
|
const pG = ctx.createLinearGradient(PADDLE_MARGIN, 0, PADDLE_MARGIN+PADDLE_W, 0);
|
||||||
|
pG.addColorStop(0,'#3ab0a5'); pG.addColorStop(1,'#4ecdc4');
|
||||||
|
ctx.fillStyle=pG; ctx.shadowColor='#4ecdc4'; ctx.shadowBlur=15;
|
||||||
|
roundRect(PADDLE_MARGIN, playerY, PADDLE_W, PADDLE_H, 4); ctx.shadowBlur=0;
|
||||||
|
|
||||||
|
const cG = ctx.createLinearGradient(W-PADDLE_MARGIN-PADDLE_W, 0, W-PADDLE_MARGIN, 0);
|
||||||
|
cG.addColorStop(0,'#e85d2a'); cG.addColorStop(1,'#ff6b35');
|
||||||
|
ctx.fillStyle=cG; ctx.shadowColor='#ff6b35'; ctx.shadowBlur=15;
|
||||||
|
roundRect(W-PADDLE_MARGIN-PADDLE_W, cpuY, PADDLE_W, PADDLE_H, 4); ctx.shadowBlur=0;
|
||||||
|
|
||||||
|
// Ball trail
|
||||||
|
trails.forEach(t => {
|
||||||
|
ctx.beginPath(); ctx.arc(t.x, t.y, BALL_R*t.life*0.6, 0, Math.PI*2);
|
||||||
|
ctx.fillStyle=`rgba(255,255,255,${t.life*0.12})`; ctx.fill();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ball
|
||||||
|
ctx.beginPath(); ctx.arc(ballX, ballY, BALL_R, 0, Math.PI*2);
|
||||||
|
ctx.fillStyle='#fff'; ctx.shadowColor='#fff'; ctx.shadowBlur=20; ctx.fill(); ctx.shadowBlur=0;
|
||||||
|
|
||||||
|
// Particles
|
||||||
|
particles.forEach(p => {
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.r*p.life, 0, Math.PI*2);
|
||||||
|
ctx.fillStyle=p.color; ctx.globalAlpha=p.life; ctx.fill(); ctx.globalAlpha=1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Big score overlay
|
||||||
|
ctx.font='72px "Press Start 2P", monospace';
|
||||||
|
ctx.textAlign='center'; ctx.textBaseline='middle';
|
||||||
|
ctx.fillStyle='rgba(78,205,196,.2)'; ctx.shadowColor='rgba(78,205,196,.08)'; ctx.shadowBlur=30;
|
||||||
|
ctx.fillText(playerScore, W*0.3, H/2); ctx.shadowBlur=0;
|
||||||
|
ctx.fillStyle='rgba(255,107,53,.2)'; ctx.shadowColor='rgba(255,107,53,.08)'; ctx.shadowBlur=30;
|
||||||
|
ctx.fillText(cpuScore, W*0.7, H/2); ctx.shadowBlur=0;
|
||||||
|
ctx.textBaseline='alphabetic';
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThiolaWall(x, y, w, h, pulseAlpha) {
|
||||||
|
const g = ctx.createLinearGradient(x, 0, x+w, 0);
|
||||||
|
g.addColorStop(0, `rgba(0,184,148,${0.9*pulseAlpha})`);
|
||||||
|
g.addColorStop(0.5, `rgba(85,239,196,${0.7*pulseAlpha})`);
|
||||||
|
g.addColorStop(1, `rgba(78,205,196,${0.35*pulseAlpha})`);
|
||||||
|
ctx.fillStyle=g; ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.fillStyle=`rgba(255,255,255,${0.06*pulseAlpha})`; ctx.fillRect(x+w-3, y, 3, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThiolaText(cx, cy, segH) {
|
||||||
|
if (segH < 50) return;
|
||||||
|
ctx.save(); ctx.translate(cx, cy); ctx.rotate(-Math.PI/2);
|
||||||
|
const fs = Math.min(18, segH * 0.14);
|
||||||
|
ctx.font = `900 ${fs}px 'Orbitron', monospace`;
|
||||||
|
ctx.textAlign='center'; ctx.textBaseline='middle';
|
||||||
|
ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fillText('THIOLA', 1, 1);
|
||||||
|
ctx.fillStyle='rgba(255,255,255,.93)'; ctx.shadowColor='rgba(85,239,196,.8)'; ctx.shadowBlur=12;
|
||||||
|
ctx.fillText('THIOLA', 0, 0); ctx.shadowBlur=0;
|
||||||
|
if (segH > 170) {
|
||||||
|
ctx.fillStyle='rgba(255,255,255,.45)';
|
||||||
|
const off = segH * 0.3;
|
||||||
|
ctx.fillText('THIOLA', -off, 0); ctx.fillText('THIOLA', off, 0);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundRect(x, y, w, h, r) {
|
||||||
|
ctx.beginPath(); ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y);
|
||||||
|
ctx.quadraticCurveTo(x+w, y, x+w, y+r); ctx.lineTo(x+w, y+h-r);
|
||||||
|
ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h); ctx.lineTo(x+r, y+h);
|
||||||
|
ctx.quadraticCurveTo(x, y+h, x, y+h-r); ctx.lineTo(x, y+r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x+r, y); ctx.closePath(); ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loop ───
|
||||||
|
function loop() { update(); draw(); requestAnimationFrame(loop); }
|
||||||
|
|
||||||
|
function startGame(mode) {
|
||||||
|
gameMode = mode;
|
||||||
|
document.getElementById('hud-plabel').textContent = mode === 'pvp' ? 'SP1' : 'SPIELER';
|
||||||
|
document.getElementById('hud-clabel').textContent = mode === 'pvp' ? 'SP2' : 'CPU';
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
if (document.activeElement && document.activeElement.blur) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
running = true; level = 1; coveragePercent = 0;
|
||||||
|
playerMsgIndex = 0; cpuMsgIndex = 0;
|
||||||
|
startLevel();
|
||||||
|
}
|
||||||
|
|
||||||
|
startBtnCpu.addEventListener('click', () => startGame('cpu'));
|
||||||
|
startBtnPvp.addEventListener('click', () => startGame('pvp'));
|
||||||
|
|
||||||
|
calcGap(); resetBall(1); draw(); loop();
|
||||||
|
})();
|
||||||
217
thiola-pong/pong-thiola.html
Normal file
217
thiola-pong/pong-thiola.html
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PONG – Thiola Edition</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Orbitron:wght@400;700;900&display=swap');
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #0a0a12;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hud {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 800px;
|
||||||
|
padding: 0 20px;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
#hud .level-display { color: #ff6b35; font-weight: 700; font-size: 13px; text-shadow: 0 0 10px rgba(255,107,53,.5); }
|
||||||
|
#hud .shield-display { color: #4ecdc4; font-size: 11px; }
|
||||||
|
|
||||||
|
#game-container {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid #1a1a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 40px rgba(78,205,196,.08), 0 0 80px rgba(255,107,53,.05), inset 0 0 60px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas { display: block; background: #0d0d1a; }
|
||||||
|
|
||||||
|
/* ─── Start overlay ─── */
|
||||||
|
#overlay {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
background: rgba(10,10,18,.94); backdrop-filter: blur(8px);
|
||||||
|
z-index: 10; transition: opacity .4s;
|
||||||
|
}
|
||||||
|
#overlay.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
#overlay h1 { font-family: 'Press Start 2P', monospace; font-size: 28px; color: #ff6b35; text-shadow: 0 0 20px rgba(255,107,53,.6); margin-bottom: 8px; letter-spacing: 4px; }
|
||||||
|
#overlay .subtitle { font-size: 12px; color: #4ecdc4; margin-bottom: 28px; letter-spacing: 2px; }
|
||||||
|
#overlay .info-box { background: rgba(255,255,255,.04); border: 1px solid rgba(78,205,196,.15); border-radius: 8px; padding: 18px 28px; margin-bottom: 28px; text-align: center; max-width: 520px; }
|
||||||
|
#overlay .info-box p { font-size: 10px; color: #999; line-height: 2.1; }
|
||||||
|
.hl { color: #ff6b35; font-weight: 700; }
|
||||||
|
.hl2 { color: #4ecdc4; font-weight: 700; }
|
||||||
|
|
||||||
|
/* ─── Buttons ─── */
|
||||||
|
.game-btn {
|
||||||
|
font-family: 'Orbitron', monospace; font-size: 13px; font-weight: 700; color: #0d0d1a;
|
||||||
|
background: linear-gradient(135deg, #ff6b35, #ff8c42); border: none;
|
||||||
|
padding: 14px 40px; border-radius: 6px; cursor: pointer; letter-spacing: 2px; text-transform: uppercase;
|
||||||
|
transition: transform .15s, box-shadow .15s; box-shadow: 0 4px 20px rgba(255,107,53,.3);
|
||||||
|
}
|
||||||
|
.game-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 30px rgba(255,107,53,.5); }
|
||||||
|
.game-btn.thiola { background: linear-gradient(135deg, #00b894, #55efc4); box-shadow: 0 4px 20px rgba(0,184,148,.3); }
|
||||||
|
.game-btn.thiola:hover { box-shadow: 0 6px 30px rgba(0,184,148,.5); }
|
||||||
|
|
||||||
|
/* ─── Full-screen popup (level transitions) ─── */
|
||||||
|
#popup {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
background: rgba(10,10,18,.94); backdrop-filter: blur(8px);
|
||||||
|
z-index: 20; opacity: 0; pointer-events: none; transition: opacity .4s; padding: 30px;
|
||||||
|
}
|
||||||
|
#popup.show { opacity: 1; pointer-events: auto; }
|
||||||
|
#popup h2 { font-family: 'Press Start 2P', monospace; font-size: 18px; color: #4ecdc4; text-shadow: 0 0 20px rgba(78,205,196,.5); margin-bottom: 18px; text-align: center; line-height: 1.5; }
|
||||||
|
.edu-card { background: rgba(255,255,255,.04); border: 1px solid rgba(78,205,196,.2); border-radius: 10px; padding: 24px 32px; max-width: 540px; margin-bottom: 22px; text-align: center; }
|
||||||
|
.edu-card .edu-icon { font-size: 36px; margin-bottom: 10px; display: block; }
|
||||||
|
.edu-card .edu-title { font-size: 12px; font-weight: 900; color: #4ecdc4; margin-bottom: 12px; letter-spacing: 1px; text-transform: uppercase; }
|
||||||
|
.edu-card .edu-text { font-size: 11px; color: #bbb; line-height: 2; }
|
||||||
|
.edu-card .edu-text strong { color: #fff; }
|
||||||
|
.edu-card .edu-text .thl { color: #55efc4; font-weight: 900; }
|
||||||
|
.edu-card .edu-text .warn { color: #ff6b35; font-weight: 700; }
|
||||||
|
#popup .popup-sub { font-size: 10px; color: #666; margin-bottom: 18px; }
|
||||||
|
|
||||||
|
.final-summary { background: linear-gradient(135deg, rgba(0,184,148,.08), rgba(78,205,196,.04)); border: 1px solid rgba(0,184,148,.25); border-radius: 12px; padding: 28px 36px; max-width: 560px; margin-bottom: 22px; text-align: center; }
|
||||||
|
.final-title { font-family: 'Press Start 2P', monospace; font-size: 14px; color: #55efc4; margin-bottom: 16px; text-shadow: 0 0 15px rgba(85,239,196,.4); }
|
||||||
|
.final-text { font-size: 11px; color: #ccc; line-height: 2; }
|
||||||
|
.final-text strong { color: #fff; }
|
||||||
|
.pathway { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 18px 0; font-size: 11px; font-weight: 700; flex-wrap: wrap; }
|
||||||
|
.pathway .step { background: rgba(78,205,196,.12); border: 1px solid rgba(78,205,196,.3); border-radius: 6px; padding: 8px 14px; color: #4ecdc4; white-space: nowrap; }
|
||||||
|
.pathway .arrow { color: #ff6b35; font-size: 16px; }
|
||||||
|
|
||||||
|
/* ─── Goal flash banner (in-game edu) ─── */
|
||||||
|
#goal-flash {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
z-index: 15; opacity: 0; pointer-events: none; transition: opacity .3s;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
#goal-flash.show { opacity: 1; pointer-events: auto; }
|
||||||
|
#goal-flash.player-goal { background: rgba(0,30,25,.88); }
|
||||||
|
#goal-flash.cpu-goal { background: rgba(40,10,5,.88); }
|
||||||
|
|
||||||
|
#goal-flash .flash-score {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
}
|
||||||
|
#goal-flash.player-goal .flash-score { color: #4ecdc4; text-shadow: 0 0 20px rgba(78,205,196,.6); }
|
||||||
|
#goal-flash.cpu-goal .flash-score { color: #ff6b35; text-shadow: 0 0 20px rgba(255,107,53,.6); }
|
||||||
|
|
||||||
|
#goal-flash .flash-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px 32px;
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
#goal-flash.player-goal .flash-card { background: rgba(78,205,196,.08); border: 1px solid rgba(78,205,196,.25); }
|
||||||
|
#goal-flash.cpu-goal .flash-card { background: rgba(255,107,53,.06); border: 1px solid rgba(255,107,53,.2); }
|
||||||
|
|
||||||
|
.flash-card .flash-icon { font-size: 28px; margin-bottom: 8px; display: block; }
|
||||||
|
.flash-card .flash-title { font-size: 11px; font-weight: 900; letter-spacing: 1px; margin-bottom: 10px; text-transform: uppercase; }
|
||||||
|
#goal-flash.player-goal .flash-title { color: #4ecdc4; }
|
||||||
|
#goal-flash.cpu-goal .flash-title { color: #ff6b35; }
|
||||||
|
.flash-card .flash-text { font-size: 11px; color: #ccc; line-height: 1.9; }
|
||||||
|
.flash-card .flash-text strong { color: #fff; }
|
||||||
|
.flash-card .flash-text .thl { color: #55efc4; font-weight: 900; }
|
||||||
|
.flash-card .flash-text .warn { color: #ff6b35; font-weight: 700; }
|
||||||
|
|
||||||
|
#goal-flash .flash-continue {
|
||||||
|
font-size: 10px; color: #666; letter-spacing: 1px;
|
||||||
|
animation: pulse-hint 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-hint { 0%,100% { opacity: .5; } 50% { opacity: 1; } }
|
||||||
|
|
||||||
|
#controls-hint { font-size: 9px; color: #444; letter-spacing: 1px; margin-top: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="game-wrapper">
|
||||||
|
<div id="hud">
|
||||||
|
<div class="level-display">LEVEL <span id="hud-level">1</span> / 3</div>
|
||||||
|
<div class="shield-display">THIOLA-SCHUTZ: <span id="hud-shield">0</span>%</div>
|
||||||
|
<div style="color:#aaa; font-size:12px;">
|
||||||
|
<span id="hud-plabel">SPIELER</span> <span id="hud-pscore" style="color:#4ecdc4; font-weight:700">0</span>
|
||||||
|
:
|
||||||
|
<span id="hud-cscore" style="color:#ff6b35; font-weight:700">0</span> <span id="hud-clabel">CPU</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game-container">
|
||||||
|
<canvas id="canvas" width="800" height="500"></canvas>
|
||||||
|
|
||||||
|
<div id="overlay">
|
||||||
|
<h1>PONG</h1>
|
||||||
|
<div class="subtitle">THIOLA EDITION</div>
|
||||||
|
<div class="info-box">
|
||||||
|
<p>
|
||||||
|
Nierensteine treffen auf dein Tor — kannst du sie abwehren?<br>
|
||||||
|
Gewinne <span class="hl">3 Level</span> mit jeweils <span class="hl">5 Punkten</span> Vorsprung.<br>
|
||||||
|
Ab Level 2 schützt <span class="hl2">Thiola</span> dein Tor!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:#888; margin-bottom:14px; letter-spacing:1px;">SPIELMODUS WÄHLEN</div>
|
||||||
|
<div style="display:flex; gap:14px; flex-wrap:wrap; justify-content:center;">
|
||||||
|
<button class="game-btn" id="start-btn-cpu">SPIELER VS. CPU</button>
|
||||||
|
<button class="game-btn thiola" id="start-btn-pvp">SPIELER VS. SPIELER</button>
|
||||||
|
</div>
|
||||||
|
<div id="controls-info" style="font-size:9px; color:#666; margin-top:18px; line-height:1.8; text-align:center;">
|
||||||
|
<span style="color:#4ecdc4">SPIELER 1 (links):</span> W / S oder Maus<br>
|
||||||
|
<span style="color:#ff6b35">SPIELER 2 (rechts):</span> ↑ / ↓ Pfeiltasten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Goal flash (in-game edu per goal) -->
|
||||||
|
<div id="goal-flash">
|
||||||
|
<div class="flash-score" id="flash-score"></div>
|
||||||
|
<div class="flash-card">
|
||||||
|
<span class="flash-icon" id="flash-icon"></span>
|
||||||
|
<div class="flash-title" id="flash-title"></div>
|
||||||
|
<div class="flash-text" id="flash-text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flash-continue">KLICKEN ODER TASTE DRÜCKEN</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Level transition popup -->
|
||||||
|
<div id="popup">
|
||||||
|
<h2 id="popup-title"></h2>
|
||||||
|
<div id="popup-content"></div>
|
||||||
|
<div class="popup-sub" id="popup-sub"></div>
|
||||||
|
<button class="game-btn" id="popup-btn">WEITER</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls-hint">PONG · THIOLA EDITION</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="constants.js"></script>
|
||||||
|
<script src="game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
thiola-pong/thiola-pong-konzept.docx
Normal file
BIN
thiola-pong/thiola-pong-konzept.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user