add music, better sync, particles
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m18s
BIN
static/assets/baskeball.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 1.4 MiB |
BIN
static/assets/g-l.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
static/assets/h-l.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
static/assets/k-l-monitor.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
static/assets/k-l.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
static/assets/k-m.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
static/assets/m-l.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
static/assets/p-l.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
static/assets/pc-trash.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
static/assets/r-l.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
static/assets/sfx/coin.mp3
Normal file
BIN
static/assets/sfx/duck.mp3
Normal file
BIN
static/assets/sfx/hit.mp3
Normal file
BIN
static/assets/sfx/jump.mp3
Normal file
BIN
static/assets/sfx/music_loop.mp3
Normal file
BIN
static/assets/sfx/pickup.mp3
Normal file
BIN
static/assets/sfx/powerup.mp3
Normal file
BIN
static/assets/t-s.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
static/assets/w-l.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
@@ -8,13 +8,12 @@
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<button id="mute-btn" onclick="toggleAudioClick()">🔊</button>
|
||||
<div id="rotate-overlay">
|
||||
<div class="icon">📱↻</div>
|
||||
<p>Bitte Gerät drehen!</p>
|
||||
<small>Querformat benötigt</small>
|
||||
</div>
|
||||
|
||||
<div id="game-container">
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
|
||||
@@ -85,7 +84,7 @@
|
||||
<h2 style="color:yellow">MEINE BEWEISE</h2>
|
||||
<div id="codesList" style="font-size: 10px; line-height: 1.8;">
|
||||
</div>
|
||||
<p style="margin-top:20px; font-size:9px; color:#888;">Zeige diesen Code dem Lehrer für deinen Preis oder lösche den Eintrag.</p>
|
||||
<p style="margin-top:20px; font-size:9px; color:#888;">Zeige diesen Code für deinen Preis oder lösche den Eintrag.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,6 +134,8 @@
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/state.js"></script>
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/particles.js"></script>
|
||||
<script src="js/network.js"></script>
|
||||
<script src="js/input.js"></script>
|
||||
<script src="js/logic.js"></script>
|
||||
|
||||
57
static/js/audio.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const SOUNDS = {
|
||||
jump: new Audio('assets/sfx/jump.mp3'),
|
||||
duck: new Audio('assets/sfx/duck.mp3'),
|
||||
coin: new Audio('assets/sfx/coin.mp3'),
|
||||
hit: new Audio('assets/sfx/hit.mp3'),
|
||||
powerup: new Audio('assets/sfx/powerup.mp3'),
|
||||
music: new Audio('assets/sfx/music_loop.mp3')
|
||||
};
|
||||
|
||||
// Config
|
||||
SOUNDS.jump.volume = 0.4;
|
||||
SOUNDS.coin.volume = 0.3;
|
||||
SOUNDS.hit.volume = 0.6;
|
||||
SOUNDS.music.loop = true;
|
||||
SOUNDS.music.volume = 0.2;
|
||||
|
||||
// --- STATUS LADEN ---
|
||||
// Wir lesen den String 'true'/'false' aus dem LocalStorage
|
||||
let isMuted = localStorage.getItem('escape_muted') === 'true';
|
||||
|
||||
function playSound(name) {
|
||||
if (isMuted || !SOUNDS[name]) return;
|
||||
|
||||
const soundClone = SOUNDS[name].cloneNode();
|
||||
soundClone.volume = SOUNDS[name].volume;
|
||||
soundClone.play().catch(() => {});
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
isMuted = !isMuted;
|
||||
|
||||
// --- STATUS SPEICHERN ---
|
||||
localStorage.setItem('escape_muted', isMuted);
|
||||
|
||||
// Musik sofort pausieren/starten
|
||||
if(isMuted) {
|
||||
SOUNDS.music.pause();
|
||||
} else {
|
||||
// Nur starten, wenn wir schon im Spiel sind (user interaction needed)
|
||||
// Wir fangen Fehler ab, falls der Browser Autoplay blockiert
|
||||
SOUNDS.music.play().catch(()=>{});
|
||||
}
|
||||
|
||||
return isMuted;
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
// Nur abspielen, wenn NICHT stummgeschaltet
|
||||
if(!isMuted) {
|
||||
SOUNDS.music.play().catch(e => console.log("Audio Autoplay blocked", e));
|
||||
}
|
||||
}
|
||||
|
||||
// Getter für UI
|
||||
function getMuteState() {
|
||||
return isMuted;
|
||||
}
|
||||
@@ -1,34 +1,29 @@
|
||||
// Konstanten
|
||||
// ==========================================
|
||||
// SPIEL KONFIGURATION & KONSTANTEN
|
||||
// ==========================================
|
||||
|
||||
// Dimensionen (Muss zum Canvas passen)
|
||||
const GAME_WIDTH = 800;
|
||||
const GAME_HEIGHT = 400;
|
||||
const GRAVITY = 0.6;
|
||||
const JUMP_POWER = -12;
|
||||
const HIGH_JUMP_POWER = -16;
|
||||
const GROUND_Y = 350;
|
||||
const BASE_SPEED = 5.0;
|
||||
const CHUNK_SIZE = 60;
|
||||
const TARGET_FPS = 60;
|
||||
|
||||
// Physik (Muss exakt synchron zum Go-Server sein!)
|
||||
const GRAVITY = 1.8;
|
||||
const JUMP_POWER = -20.0; // Vorher -36.0 (Deutlich weniger!)
|
||||
const HIGH_JUMP_POWER = -28.0;// Vorher -48.0 (Boots)
|
||||
const GROUND_Y = 350; // Y-Position des Bodens
|
||||
|
||||
// Geschwindigkeit
|
||||
const BASE_SPEED = 15.0;
|
||||
|
||||
// Game Loop Einstellungen
|
||||
const TARGET_FPS = 20;
|
||||
const MS_PER_TICK = 1000 / TARGET_FPS;
|
||||
const CHUNK_SIZE = 20; // Intervall für Berechnungen (Legacy)
|
||||
|
||||
// Debugging
|
||||
// true = Zeigt Hitboxen (Grün) und Server-Daten (Cyan)
|
||||
const DEBUG_SYNC = true;
|
||||
const SYNC_TOLERANCE = 5.0;
|
||||
|
||||
// RNG Klasse
|
||||
class PseudoRNG {
|
||||
constructor(seed) {
|
||||
this.state = BigInt(seed);
|
||||
}
|
||||
nextFloat() {
|
||||
const a = 1664525n; const c = 1013904223n; const m = 4294967296n;
|
||||
this.state = (this.state * a + c) % m;
|
||||
return Number(this.state) / Number(m);
|
||||
}
|
||||
nextRange(min, max) {
|
||||
return min + (this.nextFloat() * (max - min));
|
||||
}
|
||||
pick(array) {
|
||||
if (!array || array.length === 0) return null;
|
||||
const idx = Math.floor(this.nextRange(0, array.length));
|
||||
return array[idx];
|
||||
}
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
@@ -1,45 +1,114 @@
|
||||
// ==========================================
|
||||
// INPUT HANDLING (WEBSOCKET VERSION)
|
||||
// ==========================================
|
||||
|
||||
function handleInput(action, active) {
|
||||
if (isGameOver) { if(active) location.reload(); return; }
|
||||
|
||||
const relativeTick = currentTick - lastSentTick;
|
||||
// 1. Game Over Reset
|
||||
if (isGameOver) {
|
||||
if(active) location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. JUMP LOGIK
|
||||
if (action === "JUMP" && active) {
|
||||
// Wir prüfen lokal, ob wir springen dürfen (Client Prediction)
|
||||
if (player.grounded && !isCrouching) {
|
||||
|
||||
// A. Sofort lokal anwenden (damit es sich direkt anfühlt)
|
||||
player.vy = JUMP_POWER;
|
||||
player.grounded = false;
|
||||
inputLog.push({ t: relativeTick, act: "JUMP" });
|
||||
|
||||
playSound('jump');
|
||||
spawnParticles(player.x + 15, player.y + 50, 'dust', 5); // Staubwolke an den Füßen
|
||||
|
||||
// B. An Server senden ("Ich habe JETZT gedrückt")
|
||||
// Die Funktion sendInput ist in network.js definiert
|
||||
if (typeof sendInput === "function") {
|
||||
sendInput("input", "JUMP");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. DUCK LOGIK
|
||||
if (action === "DUCK") {
|
||||
// Status merken, um unnötiges Senden zu vermeiden
|
||||
const wasCrouching = isCrouching;
|
||||
|
||||
// A. Lokal anwenden
|
||||
isCrouching = active;
|
||||
|
||||
// B. An Server senden (State Change: Start oder Ende)
|
||||
if (wasCrouching !== isCrouching) {
|
||||
if (typeof sendInput === "function") {
|
||||
sendInput("input", active ? "DUCK_START" : "DUCK_END");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action === "DUCK") { isCrouching = active; }
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
// ==========================================
|
||||
// EVENT LISTENERS
|
||||
// ==========================================
|
||||
|
||||
// Tastatur
|
||||
window.addEventListener('keydown', (e) => {
|
||||
// Ignorieren, wenn User gerade Name in Highscore tippt
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true);
|
||||
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true);
|
||||
if (e.code === 'F9') {
|
||||
e.preventDefault();
|
||||
console.log("🐞 Fordere Debug-Daten vom Server an...");
|
||||
if (typeof sendInput === "function") {
|
||||
// Wir senden ein manuelles Paket, da sendInput meist nur für Game-Inputs ist
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "debug" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
|
||||
});
|
||||
|
||||
// Maus / Touch (Einfach)
|
||||
window.addEventListener('mousedown', (e) => {
|
||||
// Nur Linksklick und nur auf dem Canvas
|
||||
if (e.target === canvas && e.button === 0) handleInput("JUMP", true);
|
||||
});
|
||||
|
||||
// Touch Logic
|
||||
// Touch (Swipe Gesten)
|
||||
let touchStartY = 0;
|
||||
|
||||
window.addEventListener('touchstart', (e) => {
|
||||
if(e.target === canvas) { e.preventDefault(); touchStartY = e.touches[0].clientY; }
|
||||
if(e.target === canvas) {
|
||||
e.preventDefault();
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('touchend', (e) => {
|
||||
if(e.target === canvas) {
|
||||
e.preventDefault();
|
||||
const diff = e.changedTouches[0].clientY - touchStartY;
|
||||
if (diff < -30) handleInput("JUMP", true);
|
||||
else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); }
|
||||
else if (Math.abs(diff) < 10) handleInput("JUMP", true);
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
const diff = touchEndY - touchStartY;
|
||||
|
||||
// Nach oben wischen oder Tippen = Sprung
|
||||
if (diff < -30) {
|
||||
handleInput("JUMP", true);
|
||||
}
|
||||
// Nach unten wischen = Ducken (kurz)
|
||||
else if (diff > 30) {
|
||||
handleInput("DUCK", true);
|
||||
setTimeout(() => handleInput("DUCK", false), 800);
|
||||
}
|
||||
// Einfaches Tippen (wenig Bewegung) = Sprung
|
||||
else if (Math.abs(diff) < 10) {
|
||||
handleInput("JUMP", true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,172 +1,210 @@
|
||||
function updateGameLogic() {
|
||||
// 1. Input Logging (Ducken)
|
||||
if (isCrouching) {
|
||||
inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" });
|
||||
}
|
||||
// ===============================================
|
||||
// 1. GESCHWINDIGKEIT
|
||||
// ===============================================
|
||||
// Wir nutzen den lokalen Score für die Geschwindigkeit
|
||||
let currentSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
|
||||
if (currentSpeed > 36.0) currentSpeed = 36.0;
|
||||
|
||||
// 2. Geschwindigkeit (Basiert auf ZEIT/Ticks, nicht Score!)
|
||||
let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
|
||||
if (currentSpeed > 12.0) currentSpeed = 12.0;
|
||||
updateParticles();
|
||||
|
||||
// 3. Spieler Physik & Größe
|
||||
|
||||
player.prevY = player.y;
|
||||
|
||||
obstacleBuffer.forEach(o => o.prevX = o.x);
|
||||
platformBuffer.forEach(p => p.prevX = p.x);
|
||||
|
||||
// ===============================================
|
||||
// 2. SPIELER PHYSIK (CLIENT PREDICTION)
|
||||
// ===============================================
|
||||
const originalHeight = 50;
|
||||
const crouchHeight = 25;
|
||||
|
||||
// Hitbox & Y-Pos anpassen
|
||||
player.h = isCrouching ? crouchHeight : originalHeight;
|
||||
let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y;
|
||||
|
||||
// Alte Position (für One-Way Check)
|
||||
const oldY = player.y;
|
||||
|
||||
// Physik
|
||||
player.vy += GRAVITY;
|
||||
if (isCrouching && !player.grounded) player.vy += 2.0; // Fast Fall
|
||||
player.y += player.vy;
|
||||
if (isCrouching && !player.grounded) player.vy += 2.0;
|
||||
|
||||
if (player.y + originalHeight >= GROUND_Y) {
|
||||
player.y = GROUND_Y - originalHeight;
|
||||
let newY = player.y + player.vy;
|
||||
let landed = false;
|
||||
|
||||
// --- PLATTFORMEN ---
|
||||
if (player.vy > 0) {
|
||||
for (let plat of platformBuffer) {
|
||||
// Nur relevante Plattformen prüfen
|
||||
if (plat.x < GAME_WIDTH + 100 && plat.x > -100) {
|
||||
if (player.x + 30 > plat.x && player.x < plat.x + plat.w) {
|
||||
// "Passed Check": Vorher drüber, jetzt drauf/drunter
|
||||
const feetOld = oldY + originalHeight;
|
||||
const feetNew = newY + originalHeight;
|
||||
if (feetOld <= plat.y && feetNew >= plat.y) {
|
||||
newY = plat.y - originalHeight;
|
||||
player.vy = 0;
|
||||
landed = true;
|
||||
sendPhysicsSync(newY, 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- BODEN ---
|
||||
if (!landed && newY + originalHeight >= GROUND_Y) {
|
||||
newY = GROUND_Y - originalHeight;
|
||||
player.vy = 0;
|
||||
player.grounded = true;
|
||||
} else {
|
||||
player.grounded = false;
|
||||
landed = true;
|
||||
}
|
||||
|
||||
// 4. Hindernisse Bewegen & Kollision
|
||||
let nextObstacles = [];
|
||||
if (currentTick % 10 === 0) {
|
||||
sendPhysicsSync(player.y, player.vy);
|
||||
}
|
||||
|
||||
for (let obs of obstacles) {
|
||||
obs.x -= currentSpeed;
|
||||
player.y = newY;
|
||||
player.grounded = landed;
|
||||
|
||||
// Aufräumen, wenn links raus
|
||||
if (obs.x + obs.def.width < -50.0) continue;
|
||||
// ===============================================
|
||||
// 3. PUFFER BEWEGEN (STREAMING)
|
||||
// ===============================================
|
||||
|
||||
// --- PASSED CHECK (Wichtig!) ---
|
||||
// Wenn das Hindernis den Spieler schon passiert hat, ignorieren wir Kollisionen.
|
||||
// Das verhindert "Geister-Treffer" von hinten durch CCD.
|
||||
const paddingX = 10;
|
||||
const realRightEdge = obs.x + obs.def.width - paddingX;
|
||||
obstacleBuffer.forEach(o => o.x -= currentSpeed);
|
||||
platformBuffer.forEach(p => p.x -= currentSpeed);
|
||||
|
||||
// Spieler ist bei 50. Wir geben 5px Puffer.
|
||||
if (realRightEdge < 55) {
|
||||
nextObstacles.push(obs); // Behalten, aber keine Kollisionsprüfung mehr
|
||||
continue;
|
||||
}
|
||||
// -------------------------------
|
||||
// Aufräumen (Links raus)
|
||||
obstacleBuffer = obstacleBuffer.filter(o => o.x + (o.w||30) > -200); // Muss -200 sein
|
||||
platformBuffer = platformBuffer.filter(p => p.x + (p.w||100) > -200); // Muss -200 sein
|
||||
|
||||
// Kollisionsprüfung
|
||||
const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
|
||||
// ===============================================
|
||||
// 4. KOLLISION & TRANSFER (LOGIK + RENDER LISTE)
|
||||
// ===============================================
|
||||
|
||||
if (checkCollision(playerHitbox, obs)) {
|
||||
// A. COIN
|
||||
if (obs.def.type === "coin") {
|
||||
score += 2000;
|
||||
continue; // Entfernen
|
||||
}
|
||||
// B. POWERUP
|
||||
else if (obs.def.type === "powerup") {
|
||||
if (obs.def.id === "p_god") godModeLives = 3;
|
||||
if (obs.def.id === "p_bat") hasBat = true;
|
||||
if (obs.def.id === "p_boot") bootTicks = 600;
|
||||
lastPowerupTick = currentTick; // Für Sync merken
|
||||
continue; // Entfernen
|
||||
}
|
||||
// C. GEGNER
|
||||
else {
|
||||
if (hasBat && obs.def.type === "teacher") {
|
||||
hasBat = false;
|
||||
continue; // Zerstört
|
||||
}
|
||||
if (godModeLives > 0) {
|
||||
godModeLives--;
|
||||
continue; // Geschützt
|
||||
obstacles = [];
|
||||
platforms = [];
|
||||
const RENDER_LIMIT = 900;
|
||||
|
||||
// Hitbox definieren (für lokale Prüfung)
|
||||
const pHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
|
||||
|
||||
// --- HINDERNISSE ---
|
||||
obstacleBuffer.forEach(obs => {
|
||||
// Nur verarbeiten, wenn im Sichtbereich
|
||||
if (obs.x < RENDER_LIMIT) {
|
||||
|
||||
// A. Metadaten laden (falls noch nicht da)
|
||||
if (!obs.def) {
|
||||
let baseDef = null;
|
||||
if(gameConfig && gameConfig.obstacles) {
|
||||
baseDef = gameConfig.obstacles.find(x => x.id === obs.id);
|
||||
}
|
||||
obs.def = {
|
||||
id: obs.id,
|
||||
type: obs.type || (baseDef ? baseDef.type : "obstacle"),
|
||||
width: obs.w || (baseDef ? baseDef.width : 30),
|
||||
height: obs.h || (baseDef ? baseDef.height : 30),
|
||||
color: obs.color || (baseDef ? baseDef.color : "red"),
|
||||
image: baseDef ? baseDef.image : null,
|
||||
imgScale: baseDef ? baseDef.imgScale : 1.0,
|
||||
imgOffsetX: baseDef ? baseDef.imgOffsetX : 0,
|
||||
imgOffsetY: baseDef ? baseDef.imgOffsetY : 0
|
||||
};
|
||||
}
|
||||
|
||||
player.color = "darkred";
|
||||
if (!isGameOver) {
|
||||
sendChunk();
|
||||
gameOver("Kollision");
|
||||
// B. Kollision prüfen (Nur wenn noch nicht eingesammelt)
|
||||
// Wir nutzen 'obs.collected' als Flag, damit wir Coins nicht doppelt zählen
|
||||
if (!obs.collected && !isGameOver) {
|
||||
if (checkCollision(pHitbox, obs)) {
|
||||
|
||||
const type = obs.def.type;
|
||||
const id = obs.def.id;
|
||||
|
||||
// 1. COIN
|
||||
if (type === "coin") {
|
||||
score += 2000; // Sofort addieren!
|
||||
obs.collected = true; // Markieren als "weg"
|
||||
playSound('coin');
|
||||
spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 10);
|
||||
}
|
||||
// 2. POWERUP
|
||||
else if (type === "powerup") {
|
||||
if (id === "p_god") godModeLives = 3;
|
||||
if (id === "p_bat") hasBat = true;
|
||||
if (id === "p_boot") bootTicks = 600; // ca. 10 Sekunden
|
||||
playSound('powerup');
|
||||
spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 20); // Mehr Partikel
|
||||
|
||||
obs.collected = true; // Markieren als "weg"
|
||||
}
|
||||
// 3. GEGNER (Teacher/Obstacle)
|
||||
else {
|
||||
// Baseballschläger vs Lehrer
|
||||
if (hasBat && type === "teacher") {
|
||||
hasBat = false;
|
||||
obs.collected = true; // Wegschlagen
|
||||
playSound('hit');
|
||||
spawnParticles(obs.x, obs.y, 'explosion', 5);
|
||||
// Effekt?
|
||||
}
|
||||
// Godmode (Schild)
|
||||
else if (godModeLives > 0) {
|
||||
godModeLives--;
|
||||
// Optional: Gegner entfernen oder durchlaufen lassen?
|
||||
// Hier entfernen wir ihn, damit man nicht 2 Leben im selben Objekt verliert
|
||||
obs.collected = true;
|
||||
}
|
||||
// TOT
|
||||
else {
|
||||
console.log("💥 Kollision!");
|
||||
player.color = "darkred";
|
||||
gameOver("Kollision");
|
||||
playSound('hit');
|
||||
spawnParticles(player.x + 15, player.y + 25, 'explosion', 50); // Riesige Explosion
|
||||
if (typeof sendInput === "function") sendInput("input", "DEATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextObstacles.push(obs);
|
||||
}
|
||||
obstacles = nextObstacles;
|
||||
|
||||
// 5. Spawning (Zeitbasiert & Synchron)
|
||||
|
||||
// Fallback für Init
|
||||
if (typeof nextSpawnTick === 'undefined' || nextSpawnTick === 0) {
|
||||
nextSpawnTick = currentTick + 50;
|
||||
}
|
||||
|
||||
if (currentTick >= nextSpawnTick && gameConfig) {
|
||||
// A. Nächsten Termin berechnen
|
||||
const gapPixel = Math.floor(400 + rng.nextRange(0, 500));
|
||||
const ticksToWait = Math.floor(gapPixel / currentSpeed);
|
||||
nextSpawnTick = currentTick + ticksToWait;
|
||||
|
||||
// B. Position setzen (Fix rechts außen)
|
||||
let spawnX = GAME_WIDTH + 50;
|
||||
|
||||
// C. Objekt auswählen
|
||||
const isBossPhase = (currentTick % 1500) > 1200;
|
||||
let possibleObs = [];
|
||||
|
||||
gameConfig.obstacles.forEach(def => {
|
||||
if (isBossPhase) {
|
||||
if (def.id === "principal" || def.id === "trashcan") possibleObs.push(def);
|
||||
} else {
|
||||
if (def.id === "principal") return;
|
||||
// Eraser erst ab Tick 3000
|
||||
if (def.id === "eraser" && currentTick < 3000) return;
|
||||
possibleObs.push(def);
|
||||
// C. Zur Render-Liste hinzufügen (Nur wenn NICHT eingesammelt)
|
||||
if (!obs.collected) {
|
||||
obstacles.push(obs);
|
||||
}
|
||||
});
|
||||
|
||||
let def = rng.pick(possibleObs);
|
||||
|
||||
// RNG Sync: Speech
|
||||
let speech = null;
|
||||
if (def && def.canTalk) {
|
||||
if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines);
|
||||
}
|
||||
});
|
||||
|
||||
// RNG Sync: Powerup Rarity
|
||||
if (def && def.type === "powerup") {
|
||||
if (rng.nextFloat() > 0.1) def = null;
|
||||
// --- PLATTFORMEN ---
|
||||
platformBuffer.forEach(plat => {
|
||||
if (plat.x < RENDER_LIMIT) {
|
||||
platforms.push(plat);
|
||||
}
|
||||
|
||||
if (def) {
|
||||
const yOffset = def.yOffset || 0;
|
||||
obstacles.push({
|
||||
x: spawnX,
|
||||
y: GROUND_Y - def.height - yOffset,
|
||||
def: def,
|
||||
speech: speech
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Robuste Kollisionsprüfung
|
||||
function checkCollision(p, obs) {
|
||||
const paddingX = 10;
|
||||
const paddingY_Top = (obs.def.type === "teacher") ? 25 : 10;
|
||||
const paddingY_Bottom = 5;
|
||||
const def = obs.def || {};
|
||||
const w = def.width || obs.w || 30;
|
||||
const h = def.height || obs.h || 30;
|
||||
|
||||
// Speed-basierte Hitbox-Erweiterung (CCD)
|
||||
// Wir schätzen den Speed hier, damit er ungefähr dem Server entspricht
|
||||
let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
|
||||
if (currentSpeed > 12.0) currentSpeed = 12.0;
|
||||
// Kleines Padding, damit es fair ist
|
||||
const padX = 8;
|
||||
const padY = (def.type === "teacher" || def.type === "principal") ? 20 : 5;
|
||||
|
||||
const pLeft = p.x + paddingX;
|
||||
const pRight = p.x + p.w - paddingX;
|
||||
const pTop = p.y + paddingY_Top;
|
||||
const pBottom = p.y + p.h - paddingY_Bottom;
|
||||
// Koordinaten
|
||||
const pL = p.x + padX;
|
||||
const pR = p.x + p.w - padX;
|
||||
const pT = p.y + padY;
|
||||
const pB = p.y + p.h - 5;
|
||||
|
||||
const oLeft = obs.x + paddingX;
|
||||
// Wir erweitern die Hitbox nach rechts um die Geschwindigkeit,
|
||||
// um schnelle Durchschüsse zu verhindern.
|
||||
const oRight = obs.x + obs.def.width - paddingX + currentSpeed;
|
||||
const oL = obs.x + padX;
|
||||
const oR = obs.x + w - padX;
|
||||
const oT = obs.y + padY;
|
||||
const oB = obs.y + h - 5;
|
||||
|
||||
const oTop = obs.y + paddingY_Top;
|
||||
const oBottom = obs.y + obs.def.height - paddingY_Bottom;
|
||||
|
||||
return (pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom);
|
||||
return (pR > oL && pL < oR && pB > oT && pT < oB);
|
||||
}
|
||||
@@ -1,247 +1,163 @@
|
||||
// ==========================================
|
||||
// INIT & ASSETS
|
||||
// 1. ASSETS LADEN
|
||||
// ==========================================
|
||||
async function loadAssets() {
|
||||
playerSprite.src = "assets/player.png";
|
||||
const pPromise = new Promise(resolve => {
|
||||
playerSprite.src = "assets/player.png";
|
||||
playerSprite.onload = resolve;
|
||||
playerSprite.onerror = () => { resolve(); };
|
||||
});
|
||||
|
||||
// Hintergründe laden
|
||||
const bgPromises = gameConfig.backgrounds.map((bgFile, index) => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = "assets/" + bgFile;
|
||||
img.onload = () => { bgSprites[index] = img; resolve(); };
|
||||
img.onerror = () => {
|
||||
console.warn("BG fehlt:", bgFile);
|
||||
bgSprites[index] = null;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Hindernisse laden
|
||||
const obsPromises = gameConfig.obstacles.map(def => {
|
||||
return new Promise((resolve) => {
|
||||
if (!def.image) { resolve(); return; }
|
||||
const img = new Image(); img.src = "assets/" + def.image;
|
||||
img.onload = () => { sprites[def.id] = img; resolve(); };
|
||||
img.onerror = () => { resolve(); };
|
||||
});
|
||||
});
|
||||
|
||||
// Player laden (kleiner Promise Wrapper)
|
||||
const pPromise = new Promise(r => {
|
||||
playerSprite.onload = r;
|
||||
playerSprite.onerror = r;
|
||||
const obsPromises = gameConfig.obstacles.map(def => {
|
||||
return new Promise((resolve) => {
|
||||
if (!def.image) { resolve(); return; }
|
||||
const img = new Image();
|
||||
img.src = "assets/" + def.image;
|
||||
img.onload = () => { sprites[def.id] = img; resolve(); };
|
||||
img.onerror = () => { resolve(); };
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all([pPromise, ...bgPromises, ...obsPromises]);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// START LOGIK
|
||||
// 2. SPIEL STARTEN
|
||||
// ==========================================
|
||||
window.startGameClick = async function() {
|
||||
if (!isLoaded) return;
|
||||
|
||||
startScreen.style.display = 'none';
|
||||
document.body.classList.add('game-active');
|
||||
try {
|
||||
const sRes = await fetch('/api/start', {method:'POST'});
|
||||
const sData = await sRes.json();
|
||||
sessionID = sData.sessionId;
|
||||
rng = new PseudoRNG(sData.seed);
|
||||
isGameRunning = true;
|
||||
maxRawBgIndex = 0;
|
||||
lastTime = performance.now();
|
||||
resize();
|
||||
} catch(e) {
|
||||
alert("Start Fehler: " + e.message);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// Score Reset visuell
|
||||
score = 0;
|
||||
const scoreEl = document.getElementById('score');
|
||||
if (scoreEl) scoreEl.innerText = "0";
|
||||
|
||||
// WebSocket Start
|
||||
startMusic();
|
||||
connectGame();
|
||||
resize();
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// SCORE EINTRAGEN
|
||||
// 3. GAME OVER & HIGHSCORE LOGIK
|
||||
// ==========================================
|
||||
window.gameOver = function(reason) {
|
||||
if (isGameOver) return;
|
||||
isGameOver = true;
|
||||
console.log("Game Over:", reason);
|
||||
|
||||
const finalScore = Math.floor(score / 10);
|
||||
const currentHighscore = localStorage.getItem('escape_highscore') || 0;
|
||||
|
||||
if (finalScore > currentHighscore) {
|
||||
localStorage.setItem('escape_highscore', finalScore);
|
||||
}
|
||||
|
||||
if (gameOverScreen) {
|
||||
gameOverScreen.style.display = 'flex';
|
||||
document.getElementById('finalScore').innerText = finalScore;
|
||||
|
||||
// Input wieder anzeigen
|
||||
document.getElementById('inputSection').style.display = 'flex';
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
|
||||
// Liste laden
|
||||
loadLeaderboard();
|
||||
}
|
||||
};
|
||||
|
||||
// Name absenden (Button Click)
|
||||
window.submitScore = async function() {
|
||||
const nameInput = document.getElementById('playerNameInput');
|
||||
const name = nameInput.value;
|
||||
const name = nameInput.value.trim();
|
||||
const btn = document.getElementById('submitBtn');
|
||||
|
||||
if (!name) return alert("Namen eingeben!");
|
||||
if (!name) return alert("Bitte Namen eingeben!");
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/submit-name', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ sessionId: sessionID, name: name })
|
||||
body: JSON.stringify({ sessionId: sessionID, name: name }) // sessionID aus state.js
|
||||
});
|
||||
if (!res.ok) throw new Error("Server Error");
|
||||
|
||||
if (!res.ok) throw new Error("Fehler beim Senden");
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Code lokal speichern (Claims)
|
||||
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
myClaims.push({
|
||||
name: name, score: Math.floor(score / 10), code: data.claimCode,
|
||||
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
|
||||
name: name,
|
||||
score: Math.floor(score / 10),
|
||||
code: data.claimCode,
|
||||
date: new Date().toLocaleString('de-DE'),
|
||||
sessionId: sessionID
|
||||
});
|
||||
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
|
||||
|
||||
// UI Update
|
||||
document.getElementById('inputSection').style.display = 'none';
|
||||
loadLeaderboard();
|
||||
alert(`Gespeichert! Code: ${data.claimCode}`);
|
||||
alert(`Gespeichert! Dein Code: ${data.claimCode}`);
|
||||
|
||||
} catch (e) {
|
||||
alert("Fehler: " + e.message);
|
||||
console.error(e);
|
||||
alert("Fehler beim Speichern: " + e.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// MEINE CODES & LÖSCHEN
|
||||
// ==========================================
|
||||
window.showMyCodes = function() {
|
||||
if(window.openModal) window.openModal('codes');
|
||||
|
||||
const listEl = document.getElementById('codesList');
|
||||
if(!listEl) return;
|
||||
|
||||
|
||||
const rawClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
|
||||
if (rawClaims.length === 0) {
|
||||
listEl.innerHTML = "<div style='padding:10px; text-align:center; color:#666;'>Keine Codes gespeichert.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedClaims = rawClaims
|
||||
.map((item, index) => ({ ...item, originalIndex: index }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
let html = "";
|
||||
|
||||
|
||||
sortedClaims.forEach(c => {
|
||||
const canDelete = c.sessionId ? true : false;
|
||||
const btnStyle = canDelete ? "cursor:pointer; color:#ff4444; border-color:#ff4444;" : "cursor:not-allowed; color:gray; border-color:gray;";
|
||||
const btnAttr = canDelete ? `onclick="deleteClaim(${c.originalIndex}, '${c.sessionId}', '${c.code}')"` : "disabled";
|
||||
|
||||
let rankIcon = "📄";
|
||||
if (c.score >= 10000) rankIcon = "🔥";
|
||||
if (c.score >= 5000) rankIcon = "⭐";
|
||||
|
||||
html += `
|
||||
<div style="border-bottom:1px solid #444; padding:8px 0; display:flex; justify-content:space-between; align-items:center;">
|
||||
<div style="text-align:left;">
|
||||
<span style="color:#00e5ff; font-weight:bold; font-size:12px;">${rankIcon} ${c.code}</span>
|
||||
<span style="color:#ffcc00; font-weight:bold;">(${c.score} Pkt)</span><br>
|
||||
<span style="color:#aaa; font-size:9px;">${c.name} • ${c.date}</span>
|
||||
</div>
|
||||
<button ${btnAttr}
|
||||
style="background:transparent; border:1px solid; padding:5px; font-size:9px; margin:0; ${btnStyle}">
|
||||
LÖSCHEN
|
||||
</button>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
listEl.innerHTML = html;
|
||||
};
|
||||
|
||||
window.deleteClaim = async function(index, sid, code) {
|
||||
if(!confirm("Wirklich löschen?")) return;
|
||||
try {
|
||||
const res = await fetch('/api/claim/delete', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ sessionId: sid, claimCode: code })
|
||||
});
|
||||
if (!res.ok) {
|
||||
if(!confirm("Server Fehler (evtl. schon weg). Lokal löschen?")) return;
|
||||
}
|
||||
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
claims.splice(index, 1);
|
||||
localStorage.setItem('escape_claims', JSON.stringify(claims));
|
||||
window.showMyCodes();
|
||||
loadLeaderboard();
|
||||
} catch(e) { alert("Verbindungsfehler!"); }
|
||||
};
|
||||
|
||||
// Bestenliste laden (Game Over Screen)
|
||||
async function loadLeaderboard() {
|
||||
try {
|
||||
// sessionID wird mitgesendet, um den eigenen Eintrag zu markieren
|
||||
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
|
||||
const entries = await res.json();
|
||||
|
||||
let html = "<h3 style='margin-bottom:5px'>BESTENLISTE</h3>";
|
||||
let html = "<h3 style='margin-bottom:5px; color:#ffcc00;'>BESTENLISTE</h3>";
|
||||
|
||||
if(entries.length === 0) html += "<div>Noch keine Einträge.</div>";
|
||||
|
||||
entries.forEach(e => {
|
||||
const color = e.isMe ? "yellow" : "white";
|
||||
const bgStyle = e.isMe ? "background:rgba(255,255,0,0.1);" : "";
|
||||
|
||||
const betterThanMe = e.rank - 1;
|
||||
let infoText = "";
|
||||
|
||||
if (e.isMe && betterThanMe > 0) {
|
||||
infoText = `<div style='font-size:8px; color:#aaa;'>(${betterThanMe} waren besser)</div>`;
|
||||
} else if (e.isMe && betterThanMe === 0) {
|
||||
infoText = `<div style='font-size:8px; color:#ffcc00;'>👑 NIEMAND ist besser!</div>`;
|
||||
}
|
||||
const color = e.isMe ? "cyan" : "white"; // Eigener Name in Cyan
|
||||
const bgStyle = e.isMe ? "background:rgba(0,255,255,0.1);" : "";
|
||||
|
||||
html += `
|
||||
<div style="border-bottom:1px dotted #444; padding:5px; ${bgStyle} margin-bottom:2px;">
|
||||
<div style="display:flex; justify-content:space-between; color:${color};">
|
||||
<span>#${e.rank} ${e.name.toUpperCase()}</span>
|
||||
<span>${Math.floor(e.score/10)}</span>
|
||||
</div>
|
||||
${infoText}
|
||||
<div style="border-bottom:1px dotted #444; padding:5px; ${bgStyle} display:flex; justify-content:space-between; color:${color}; font-size:12px;">
|
||||
<span>#${e.rank} ${e.name}</span>
|
||||
<span>${Math.floor(e.score/10)}</span>
|
||||
</div>`;
|
||||
|
||||
if(e.rank === 3 && entries.length > 3 && !entries[3].isMe) {
|
||||
html += "<div style='text-align:center; color:gray; font-size:8px;'>...</div>";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('leaderboard').innerHTML = html;
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadStartScreenLeaderboard() {
|
||||
try {
|
||||
const listEl = document.getElementById('startLeaderboardList');
|
||||
if (!listEl) return;
|
||||
const res = await fetch('/api/leaderboard');
|
||||
const entries = await res.json();
|
||||
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Noch keine Scores.</div>"; return; }
|
||||
let html = "";
|
||||
entries.forEach(e => {
|
||||
let icon = "#" + e.rank;
|
||||
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
|
||||
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function gameOver(reason) {
|
||||
if (isGameOver) return;
|
||||
isGameOver = true;
|
||||
const finalScoreVal = Math.floor(score / 10);
|
||||
const currentHighscore = localStorage.getItem('escape_highscore') || 0;
|
||||
if (finalScoreVal > currentHighscore) localStorage.setItem('escape_highscore', finalScoreVal);
|
||||
gameOverScreen.style.display = 'flex';
|
||||
document.getElementById('finalScore').innerText = finalScoreVal;
|
||||
loadLeaderboard();
|
||||
drawGame();
|
||||
} catch(e) {
|
||||
console.error("Leaderboard Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// DER FIXIERTE GAME LOOP
|
||||
// 4. GAME LOOP
|
||||
// ==========================================
|
||||
function gameLoop(timestamp) {
|
||||
requestAnimationFrame(gameLoop);
|
||||
|
||||
// 1. Wenn Assets noch nicht da sind, machen wir gar nichts
|
||||
if (!isLoaded) return;
|
||||
|
||||
// 2. PHYSIK-LOGIK (Nur wenn Spiel läuft und nicht Game Over)
|
||||
// Das hier sorgt dafür, dass der Dino stehen bleibt, wenn wir im Menü sind
|
||||
if (isGameRunning && !isGameOver) {
|
||||
|
||||
if (!lastTime) lastTime = timestamp;
|
||||
const deltaTime = timestamp - lastTime;
|
||||
lastTime = timestamp;
|
||||
@@ -254,28 +170,34 @@ function gameLoop(timestamp) {
|
||||
updateGameLogic();
|
||||
currentTick++;
|
||||
score++;
|
||||
if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk();
|
||||
accumulator -= MS_PER_TICK;
|
||||
}
|
||||
|
||||
const alpha = accumulator / MS_PER_TICK;
|
||||
|
||||
// Score im HUD
|
||||
const scoreEl = document.getElementById('score');
|
||||
if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
|
||||
}
|
||||
|
||||
// 3. RENDERING (IMMER!)
|
||||
// Das hier war das Problem. Früher stand hier "return" wenn !isGameRunning.
|
||||
// Jetzt malen wir immer. Wenn isGameRunning false ist, malt er einfach den Start-Zustand.
|
||||
drawGame();
|
||||
drawGame(isGameRunning ? accumulator / MS_PER_TICK : 1.0);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5. INIT
|
||||
// ==========================================
|
||||
async function initGame() {
|
||||
try {
|
||||
const cRes = await fetch('/api/config'); gameConfig = await cRes.json();
|
||||
const cRes = await fetch('/api/config');
|
||||
gameConfig = await cRes.json();
|
||||
|
||||
// Erst alles laden
|
||||
await loadAssets();
|
||||
await loadStartScreenLeaderboard();
|
||||
|
||||
if (typeof getMuteState === 'function') {
|
||||
updateMuteIcon(getMuteState());
|
||||
}
|
||||
|
||||
isLoaded = true;
|
||||
if(loadingText) loadingText.style.display = 'none';
|
||||
if(startBtn) startBtn.style.display = 'inline-block';
|
||||
@@ -284,10 +206,7 @@ async function initGame() {
|
||||
const hsEl = document.getElementById('localHighscore');
|
||||
if(hsEl) hsEl.innerText = savedHighscore;
|
||||
|
||||
// Loop starten (mit dummy timestamp start)
|
||||
requestAnimationFrame(gameLoop);
|
||||
|
||||
// Initiales Zeichnen erzwingen (damit Hintergrund sofort da ist)
|
||||
drawGame();
|
||||
|
||||
} catch(e) {
|
||||
@@ -296,4 +215,147 @@ async function initGame() {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Mini-Leaderboard auf Startseite
|
||||
async function loadStartScreenLeaderboard() {
|
||||
try {
|
||||
const listEl = document.getElementById('startLeaderboardList');
|
||||
if (!listEl) return;
|
||||
const res = await fetch('/api/leaderboard');
|
||||
const entries = await res.json();
|
||||
|
||||
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Keine Scores.</div>"; return; }
|
||||
|
||||
let html = "";
|
||||
entries.forEach(e => {
|
||||
let icon = "#" + e.rank;
|
||||
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
|
||||
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Audio Toggle Funktion für den Button
|
||||
window.toggleAudioClick = function() {
|
||||
// 1. Audio umschalten (in audio.js)
|
||||
const muted = toggleMute();
|
||||
|
||||
// 2. Button Icon updaten
|
||||
updateMuteIcon(muted);
|
||||
|
||||
// 3. Fokus vom Button nehmen (damit Space nicht den Button drückt, sondern springt)
|
||||
document.getElementById('mute-btn').blur();
|
||||
};
|
||||
|
||||
function updateMuteIcon(isMuted) {
|
||||
const btn = document.getElementById('mute-btn');
|
||||
if (btn) {
|
||||
btn.innerText = isMuted ? "🔇" : "🔊";
|
||||
btn.style.color = isMuted ? "#ff4444" : "white";
|
||||
btn.style.borderColor = isMuted ? "#ff4444" : "#555";
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// MEINE CODES (LOCAL STORAGE)
|
||||
// ==========================================
|
||||
|
||||
// 1. Codes anzeigen (Wird vom Button im Startscreen aufgerufen)
|
||||
window.showMyCodes = function() {
|
||||
// Modal öffnen
|
||||
openModal('codes');
|
||||
|
||||
const listEl = document.getElementById('codesList');
|
||||
if(!listEl) return;
|
||||
|
||||
// Daten aus dem Browser-Speicher holen
|
||||
const rawClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
|
||||
if (rawClaims.length === 0) {
|
||||
listEl.innerHTML = "<div style='padding:20px; text-align:center; color:#666;'>Keine Codes gespeichert.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Sortieren nach Score (Höchster zuerst)
|
||||
const sortedClaims = rawClaims
|
||||
.map((item, index) => ({ ...item, originalIndex: index }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
let html = "";
|
||||
|
||||
sortedClaims.forEach(c => {
|
||||
// Icons basierend auf Score
|
||||
let rankIcon = "📄";
|
||||
if (c.score >= 5000) rankIcon = "⭐";
|
||||
if (c.score >= 10000) rankIcon = "🔥";
|
||||
if (c.score >= 20000) rankIcon = "👑";
|
||||
|
||||
html += `
|
||||
<div style="border-bottom:1px solid #444; padding:10px 0; display:flex; justify-content:space-between; align-items:center;">
|
||||
<div style="text-align:left;">
|
||||
<span style="color:#00e5ff; font-weight:bold; font-size:14px;">${rankIcon} ${c.code}</span>
|
||||
<span style="color:#ffcc00; font-weight:bold;">(${c.score} Pkt)</span><br>
|
||||
<span style="color:#aaa; font-size:10px;">${c.name} • ${c.date}</span>
|
||||
</div>
|
||||
<button onclick="deleteClaim('${c.sessionId}', '${c.code}')"
|
||||
style="background:transparent; border:1px solid #ff4444; color:#ff4444; padding:5px 10px; font-size:10px; cursor:pointer;">
|
||||
LÖSCHEN
|
||||
</button>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
listEl.innerHTML = html;
|
||||
};
|
||||
|
||||
// 2. Code löschen (Lokal und auf Server)
|
||||
window.deleteClaim = async function(sid, code) {
|
||||
if(!confirm("Eintrag wirklich löschen?")) return;
|
||||
|
||||
// Versuch, es auf dem Server zu löschen
|
||||
try {
|
||||
await fetch('/api/claim/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ sessionId: sid, claimCode: code })
|
||||
});
|
||||
} catch(e) {
|
||||
console.warn("Server Delete fehlgeschlagen (vielleicht schon weg), lösche lokal...");
|
||||
}
|
||||
|
||||
// Lokal löschen
|
||||
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
// Wir filtern den Eintrag raus, der die gleiche SessionID UND den gleichen Code hat
|
||||
claims = claims.filter(c => c.code !== code);
|
||||
|
||||
localStorage.setItem('escape_claims', JSON.stringify(claims));
|
||||
|
||||
// Liste aktualisieren
|
||||
window.showMyCodes();
|
||||
|
||||
// Leaderboard aktualisieren (falls im Hintergrund sichtbar)
|
||||
if(document.getElementById('startLeaderboardList')) {
|
||||
loadStartScreenLeaderboard();
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// MODAL LOGIK (Fenster auf/zu)
|
||||
// ==========================================
|
||||
window.openModal = function(id) {
|
||||
const el = document.getElementById('modal-' + id);
|
||||
if(el) el.style.display = 'flex';
|
||||
}
|
||||
|
||||
window.closeModal = function() {
|
||||
const modals = document.querySelectorAll('.modal-overlay');
|
||||
modals.forEach(el => el.style.display = 'none');
|
||||
}
|
||||
|
||||
// Klick nebendran schließt Modal
|
||||
window.onclick = function(event) {
|
||||
if (event.target.classList.contains('modal-overlay')) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
initGame();
|
||||
@@ -1,199 +1,377 @@
|
||||
async function sendChunk() {
|
||||
const ticksToSend = currentTick - lastSentTick;
|
||||
if (ticksToSend <= 0) return;
|
||||
// ==========================================
|
||||
// NETZWERK LOGIK (WEBSOCKET + RTT SYNC)
|
||||
// ==========================================
|
||||
|
||||
/*
|
||||
GLOBALE VARIABLEN (aus state.js):
|
||||
- socket
|
||||
- obstacleBuffer, platformBuffer
|
||||
- currentLatencyMs, pingInterval
|
||||
- isGameRunning, isGameOver
|
||||
- score, currentTick
|
||||
*/
|
||||
|
||||
const snapshotobstacles = JSON.parse(JSON.stringify(obstacles));
|
||||
function connectGame() {
|
||||
// Alte Verbindung schließen
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sessionId: sessionID,
|
||||
inputs: [...inputLog],
|
||||
totalTicks: ticksToSend
|
||||
// Ping Timer stoppen falls aktiv
|
||||
if (typeof pingInterval !== 'undefined' && pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
|
||||
// Protokoll automatisch wählen (ws:// oder wss://)
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = proto + "//" + location.host + "/ws";
|
||||
|
||||
console.log("Verbinde zu:", url);
|
||||
socket = new WebSocket(url);
|
||||
|
||||
// --- 1. VERBINDUNG GEÖFFNET ---
|
||||
socket.onopen = () => {
|
||||
console.log("🟢 WS Verbunden. Spiel startet.");
|
||||
|
||||
// Alles zurücksetzen
|
||||
obstacleBuffer = [];
|
||||
platformBuffer = [];
|
||||
obstacles = [];
|
||||
platforms = [];
|
||||
currentLatencyMs = 0; // Reset Latenz
|
||||
|
||||
isGameRunning = true;
|
||||
isGameOver = false;
|
||||
isLoaded = true;
|
||||
|
||||
// PING LOOP STARTEN (Jede Sekunde messen)
|
||||
pingInterval = setInterval(sendPing, 1000);
|
||||
|
||||
// Game Loop anwerfen
|
||||
requestAnimationFrame(gameLoop);
|
||||
};
|
||||
|
||||
inputLog = [];
|
||||
lastSentTick = currentTick;
|
||||
// --- 2. NACHRICHT VOM SERVER ---
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/validate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
// A. PONG (Latenzmessung)
|
||||
if (msg.type === "pong") {
|
||||
const now = Date.now();
|
||||
const sentTime = msg.ts; // Server schickt unseren Timestamp zurück
|
||||
|
||||
const data = await res.json();
|
||||
// Round Trip Time (Hin + Zurück)
|
||||
const rtt = now - sentTime;
|
||||
|
||||
// Update für visuelles Debugging
|
||||
if (data.serverObs) {
|
||||
serverObstacles = data.serverObs;
|
||||
// One Way Latency (Latenz in eine Richtung)
|
||||
const latency = rtt / 2;
|
||||
|
||||
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
|
||||
compareState(snapshotobstacles, data.serverObs);
|
||||
}
|
||||
|
||||
if (data.powerups) {
|
||||
const sTick = data.serverTick;
|
||||
|
||||
if (lastPowerupTick > sTick) {
|
||||
// Glätten (Exponential Moving Average), damit Werte nicht springen
|
||||
// Wenn es der erste Wert ist, nehmen wir ihn direkt.
|
||||
if (currentLatencyMs === 0) {
|
||||
currentLatencyMs = latency;
|
||||
} else {
|
||||
godModeLives = data.powerups.godLives;
|
||||
hasBat = data.powerups.hasBat;
|
||||
bootTicks = data.powerups.bootTicks;
|
||||
// 90% alter Wert, 10% neuer Wert
|
||||
currentLatencyMs = (currentLatencyMs * 0.9) + (latency * 0.1);
|
||||
}
|
||||
|
||||
// Optional: Debugging im Log
|
||||
// console.log(`📡 Ping: ${rtt}ms | Latenz: ${currentLatencyMs.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
// B. CHUNK (Objekte empfangen)
|
||||
if (msg.type === "chunk") {
|
||||
|
||||
// 1. CLOCK SYNC (Die Zeitmaschine)
|
||||
// Wenn der Server bei Tick 204 ist und wir bei 182, müssen wir aufholen!
|
||||
// Wir addieren die geschätzte Latenz (in Ticks) auf die Serverzeit.
|
||||
// 60 FPS = 16ms/Tick. 20 TPS = 50ms/Tick.
|
||||
|
||||
const msPerTick = 1000 / 20; // WICHTIG: Wir laufen auf 20 TPS Basis!
|
||||
const latencyInTicks = Math.floor(currentLatencyMs / msPerTick);
|
||||
|
||||
// Ziel-Zeit: Server-Zeit + Übertragungsweg
|
||||
const targetTick = msg.serverTick + latencyInTicks;
|
||||
const drift = targetTick - currentTick;
|
||||
|
||||
// Wenn wir mehr als 2 Ticks abweichen -> Korrigieren
|
||||
if (Math.abs(drift) > 2) {
|
||||
// console.log(`⏰ Clock Sync: ${currentTick} -> ${targetTick} (Drift: ${drift})`);
|
||||
currentTick = targetTick; // Harter Sync, damit Physik stimmt
|
||||
}
|
||||
|
||||
// 2. PIXEL KORREKTUR (Sanfter!)
|
||||
// Wir berechnen den Speed
|
||||
let sTick = msg.serverTick;
|
||||
// Formel aus logic.js (Base 15 + Zeit)
|
||||
let currentSpeedPerTick = 15.0 + (sTick / 1000.0) * 1.5;
|
||||
if (currentSpeedPerTick > 36) currentSpeedPerTick = 36;
|
||||
|
||||
const speedPerMs = currentSpeedPerTick / msPerTick; // Speed pro MS
|
||||
|
||||
// Korrektur: Latenz * Speed
|
||||
// FIX: Wir kappen die Korrektur bei max 100px, damit Objekte nicht "teleportieren".
|
||||
let dynamicCorrection = (currentLatencyMs * speedPerMs) + 5;
|
||||
if (dynamicCorrection > 100) dynamicCorrection = 100; // Limit
|
||||
|
||||
// Puffer füllen (mit Limit)
|
||||
if (msg.obstacles) {
|
||||
msg.obstacles.forEach(o => {
|
||||
o.x -= dynamicCorrection;
|
||||
// Init für Interpolation
|
||||
o.prevX = o.x;
|
||||
obstacleBuffer.push(o);
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.platforms) {
|
||||
msg.platforms.forEach(p => {
|
||||
p.x -= dynamicCorrection;
|
||||
p.prevX = p.x;
|
||||
platformBuffer.push(p);
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.score !== undefined) score = msg.score;
|
||||
|
||||
// Powerups übernehmen (für Anzeige)
|
||||
if (msg.powerups) {
|
||||
godModeLives = msg.powerups.godLives;
|
||||
hasBat = msg.powerups.hasBat;
|
||||
bootTicks = msg.powerups.bootTicks;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Spawning Timer
|
||||
if (data.NextSpawnTick) {
|
||||
if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) {
|
||||
console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick);
|
||||
nextSpawnTick = data.nextSpawnTick;
|
||||
if (msg.type === "init") {
|
||||
console.log("📩 INIT EMPFANGEN:", msg); // <--- DEBUG LOG
|
||||
|
||||
if (msg.sessionId) {
|
||||
sessionID = msg.sessionId; // Globale Variable setzen
|
||||
console.log("🔑 Session ID gesetzt auf:", sessionID);
|
||||
} else {
|
||||
console.error("❌ INIT FEHLER: Keine sessionId im Paket!", msg);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// C. TOD (Server Authoritative)
|
||||
if (msg.type === "dead") {
|
||||
console.log("💀 Server sagt: Game Over");
|
||||
|
||||
if (data.status === "dead") {
|
||||
console.error("💀 SERVER KILL", data);
|
||||
gameOver("Vom Server gestoppt");
|
||||
} else {
|
||||
const sScore = data.verifiedScore;
|
||||
// Score Korrektur
|
||||
if (Math.abs(score - sScore) > 200) {
|
||||
console.warn(`⚠️ SCORE DRIFT: Client=${score} Server=${sScore}`);
|
||||
score = sScore;
|
||||
if (msg.score) score = msg.score;
|
||||
|
||||
// Verbindung sauber trennen
|
||||
socket.close();
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
|
||||
gameOver("Vom Server gestoppt");
|
||||
}
|
||||
|
||||
if (msg.type === "debug_sync") {
|
||||
|
||||
|
||||
|
||||
// 1. CLIENT SPEED BERECHNEN (Formel aus logic.js)
|
||||
// Wir nutzen hier 'score', da logic.js das auch tut
|
||||
let clientSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
|
||||
if (clientSpeed > 36.0) clientSpeed = 36.0;
|
||||
|
||||
// 2. SERVER SPEED HOLEN
|
||||
let serverSpeed = msg.currentSpeed || 0;
|
||||
|
||||
// 3. DIFF BERECHNEN
|
||||
let diffSpeed = clientSpeed - serverSpeed;
|
||||
let speedIcon = Math.abs(diffSpeed) < 0.01 ? "✅" : "❌";
|
||||
|
||||
console.group(`📊 SYNC REPORT (Tick: ${currentTick} vs Server: ${msg.serverTick})`);
|
||||
|
||||
// --- DER NEUE SPEED CHECK ---
|
||||
console.log(`🚀 SPEED CHECK: ${speedIcon}`);
|
||||
console.log(` Client: ${clientSpeed.toFixed(4)} px/tick (Basis: Tick ${currentTick})`);
|
||||
console.log(` Server: ${serverSpeed.toFixed(4)} px/tick (Basis: Tick ${msg.serverTick})`);
|
||||
|
||||
if (Math.abs(diffSpeed) > 0.01) {
|
||||
console.warn(`⚠️ ACHTUNG: Geschwindigkeiten weichen ab! Diff: ${diffSpeed.toFixed(4)}`);
|
||||
console.warn("Ursache: Client nutzt 'Score', Server nutzt 'Ticks'. Sind diese synchron?");
|
||||
}
|
||||
// -----------------------------
|
||||
|
||||
// 1. Hindernisse vergleichen
|
||||
generateSyncTable("Obstacles", obstacles, msg.obstacles);
|
||||
|
||||
// 2. Plattformen vergleichen
|
||||
generateSyncTable("Platforms", platforms, msg.platforms);
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Verarbeiten der Nachricht:", e);
|
||||
}
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error("Netzwerkfehler:", e);
|
||||
// --- 3. VERBINDUNG GETRENNT ---
|
||||
socket.onclose = () => {
|
||||
console.log("🔴 WS Verbindung getrennt.");
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error("WS Fehler:", error);
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PING SENDEN
|
||||
// ==========================================
|
||||
function sendPing() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
// Wir senden den aktuellen Zeitstempel
|
||||
// Der Server muss diesen im "tick" Feld zurückschicken (siehe websocket.go)
|
||||
socket.send(JSON.stringify({
|
||||
type: "ping",
|
||||
tick: Date.now() // Timestamp als Integer
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
window.submitScore = async function() {
|
||||
const nameInput = document.getElementById('playerNameInput');
|
||||
const name = nameInput.value;
|
||||
const btn = document.getElementById('submitBtn');
|
||||
|
||||
if (!name) return alert("Namen eingeben!");
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/submit-name', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ sessionId: sessionID, name: name })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
myClaims.push({
|
||||
name: name, score: Math.floor(score / 10), code: data.claimCode,
|
||||
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
|
||||
});
|
||||
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
|
||||
|
||||
document.getElementById('inputSection').style.display = 'none';
|
||||
loadLeaderboard();
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
|
||||
const entries = await res.json();
|
||||
let html = "<h3>BESTENLISTE</h3>";
|
||||
entries.forEach(e => {
|
||||
const color = e.isMe ? "yellow" : "white";
|
||||
html += `<div style="display:flex; justify-content:space-between; color:${color}; margin-bottom:5px;">
|
||||
<span>#${e.rank} ${e.name}</span><span>${Math.floor(e.score/10)}</span></div>`;
|
||||
});
|
||||
document.getElementById('leaderboard').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadStartScreenLeaderboard() {
|
||||
try {
|
||||
const listEl = document.getElementById('startLeaderboardList');
|
||||
if (!listEl) return;
|
||||
const res = await fetch('/api/leaderboard');
|
||||
const entries = await res.json();
|
||||
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Noch keine Scores.</div>"; return; }
|
||||
let html = "";
|
||||
entries.forEach(e => {
|
||||
let icon = "#" + e.rank;
|
||||
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
|
||||
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function compareState(clientObs, serverObs) {
|
||||
// 1. Anzahl prüfen
|
||||
if (clientObs.length !== serverObs.length) {
|
||||
console.error(`🚨 ANZAHL MISMATCH! Client: ${clientObs.length}, Server: ${serverObs.length}`);
|
||||
// ==========================================
|
||||
// INPUT SENDEN
|
||||
// ==========================================
|
||||
function sendInput(type, action) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: "input",
|
||||
input: action
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper für die Tabelle
|
||||
function generateSyncTable(label, clientList, serverList) {
|
||||
if (!serverList) serverList = [];
|
||||
|
||||
console.log(`--- ${label} Analyse (Ping: ${Math.round(currentLatencyMs)}ms) ---`);
|
||||
|
||||
const report = [];
|
||||
const maxLen = Math.max(clientObs.length, serverObs.length);
|
||||
let hasMajorDrift = false;
|
||||
const matchedServerIndices = new Set();
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const cli = clientObs[i];
|
||||
const srv = serverObs[i];
|
||||
// 1. Parameter für Latenz-Korrektur berechnen
|
||||
// Damit wir wissen: "Wo MÜSSTE das Server-Objekt auf dem Client sein?"
|
||||
const msPerTick = 50; // Bei 20 TPS
|
||||
|
||||
let drift = 0;
|
||||
let status = "✅ OK";
|
||||
// Speed Schätzung (gleiche Formel wie in logic.js)
|
||||
let debugSpeed = 15.0 + (score / 1000.0) * 1.5;
|
||||
if (debugSpeed > 36) debugSpeed = 36;
|
||||
|
||||
// Client Objekt vorbereiten
|
||||
let cID = "---";
|
||||
let cX = 0;
|
||||
if (cli) {
|
||||
cID = cli.def.id; // Struktur beachten: cli.def.id
|
||||
cX = cli.x;
|
||||
}
|
||||
const speedPerMs = debugSpeed / msPerTick;
|
||||
|
||||
// Server Objekt vorbereiten
|
||||
let sID = "---";
|
||||
let sX = 0;
|
||||
if (srv) {
|
||||
sID = srv.id; // Struktur vom Server: srv.id
|
||||
sX = srv.x;
|
||||
}
|
||||
// Pixel, die das Objekt wegen Ping weiter "links" sein müsste
|
||||
const latencyPx = currentLatencyMs * speedPerMs;
|
||||
|
||||
// Vergleich
|
||||
if (cli && srv) {
|
||||
// IDs unterschiedlich? (z.B. Tisch vs Lehrer)
|
||||
if (cID !== sID) {
|
||||
status = "❌ ID ERROR";
|
||||
hasMajorDrift = true;
|
||||
} else {
|
||||
drift = cX - sX;
|
||||
if (Math.abs(drift) > SYNC_TOLERANCE) {
|
||||
status = "⚠️ DRIFT";
|
||||
hasMajorDrift = true;
|
||||
}
|
||||
// 2. Client Objekte durchgehen
|
||||
clientList.forEach((cObj) => {
|
||||
let bestMatch = null;
|
||||
let bestDist = 9999;
|
||||
let bestSIdx = -1;
|
||||
|
||||
// ID sicherstellen
|
||||
const cID = cObj.def ? cObj.def.id : (cObj.id || "unknown");
|
||||
|
||||
// Passendes Server-Objekt suchen
|
||||
serverList.forEach((sObj, sIdx) => {
|
||||
if (matchedServerIndices.has(sIdx)) return;
|
||||
|
||||
const sID = sObj.id || "unknown";
|
||||
|
||||
// Match Kriterien:
|
||||
// 1. Gleiche ID (oder Plattform)
|
||||
// 2. Nähe (Wir vergleichen hier die korrigierte Position!)
|
||||
const sPosCorrected = sObj.x - latencyPx;
|
||||
const dist = Math.abs(cObj.x - sPosCorrected);
|
||||
|
||||
const isTypeMatch = (label === "Platforms") || (cID === sID);
|
||||
|
||||
// Toleranter Suchradius (500px), falls Drift groß ist
|
||||
if (isTypeMatch && dist < bestDist && dist < 500) {
|
||||
bestDist = dist;
|
||||
bestMatch = sObj;
|
||||
bestSIdx = sIdx;
|
||||
}
|
||||
} else {
|
||||
status = "❌ MISSING";
|
||||
hasMajorDrift = true;
|
||||
});
|
||||
|
||||
// Datenzeile bauen
|
||||
let serverXRaw = "---";
|
||||
let serverXCorrected = "---";
|
||||
let diffReal = "---";
|
||||
let status = "👻 GHOST (Client only)";
|
||||
|
||||
if (bestMatch) {
|
||||
matchedServerIndices.add(bestSIdx);
|
||||
|
||||
serverXRaw = bestMatch.x;
|
||||
serverXCorrected = bestMatch.x - latencyPx; // Hier rechnen wir den Ping raus
|
||||
|
||||
// Der "Wahrs" Drift: Differenz nach Latenz-Abzug
|
||||
diffReal = cObj.x - serverXCorrected;
|
||||
|
||||
// Status Bestimmung
|
||||
const absDiff = Math.abs(diffReal);
|
||||
if (absDiff < 20) status = "✅ PERFECT";
|
||||
else if (absDiff < 60) status = "🆗 OK";
|
||||
else if (absDiff < 150) status = "⚠️ DRIFT";
|
||||
else status = "🔥 BROKEN";
|
||||
}
|
||||
|
||||
// In Tabelle eintragen
|
||||
report.push({
|
||||
Index: i,
|
||||
Status: status,
|
||||
"C-ID": cID,
|
||||
"S-ID": sID,
|
||||
"C-Pos": cX.toFixed(1),
|
||||
"S-Pos": sX.toFixed(1),
|
||||
"Drift (px)": drift.toFixed(2)
|
||||
"ID": cID,
|
||||
"Client X": Math.round(cObj.x),
|
||||
"Server X (Raw)": Math.round(serverXRaw),
|
||||
"Server X (Sim)": Math.round(serverXCorrected), // Wo es sein sollte
|
||||
"Diff (Real)": typeof diffReal === 'number' ? Math.round(diffReal) : "---",
|
||||
"Status": status
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Nur loggen, wenn Fehler da sind oder alle 5 Sekunden (Tick 300)
|
||||
if (hasMajorDrift || currentTick % 300 === 0) {
|
||||
if (hasMajorDrift) console.warn("--- SYNC PROBLEME GEFUNDEN ---");
|
||||
else console.log("--- Sync Check (Routine) ---");
|
||||
// 3. Fehlende Server Objekte finden
|
||||
serverList.forEach((sObj, sIdx) => {
|
||||
if (!matchedServerIndices.has(sIdx)) {
|
||||
// Prüfen, ob es vielleicht einfach noch unsichtbar ist (Zukunft)
|
||||
const sPosCorrected = sObj.x - latencyPx;
|
||||
let status = "❌ MISSING";
|
||||
|
||||
console.table(report); // Das erstellt eine super lesbare Tabelle im Browser
|
||||
if (sPosCorrected > 850) status = "🔮 FUTURE (Buffer)"; // Noch rechts vom Screen
|
||||
if (sPosCorrected < -100) status = "🗑️ OLD (Server lag)"; // Schon links raus
|
||||
|
||||
report.push({
|
||||
"ID": sObj.id || "?",
|
||||
"Client X": "---",
|
||||
"Server X (Raw)": Math.round(sObj.x),
|
||||
"Server X (Sim)": Math.round(sPosCorrected),
|
||||
"Diff (Real)": "---",
|
||||
"Status": status
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Sortieren nach Position (links nach rechts)
|
||||
report.sort((a, b) => {
|
||||
const valA = (typeof a["Client X"] === 'number') ? a["Client X"] : a["Server X (Sim)"];
|
||||
const valB = (typeof b["Client X"] === 'number') ? b["Client X"] : b["Server X (Sim)"];
|
||||
return valA - valB;
|
||||
});
|
||||
|
||||
if (report.length > 0) console.table(report);
|
||||
else console.log("Leer.");
|
||||
}
|
||||
|
||||
function sendPhysicsSync(y, vy) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: "sync",
|
||||
y: y,
|
||||
vy: vy,
|
||||
tick: currentTick
|
||||
}));
|
||||
}
|
||||
}
|
||||
88
static/js/particles.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Globale Partikel-Liste (muss in state.js bekannt sein oder hier exportiert)
|
||||
// Wir nutzen die globale Variable 'particles' (fügen wir gleich in state.js hinzu)
|
||||
|
||||
class Particle {
|
||||
constructor(x, y, type) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.life = 1.0; // 1.0 = 100% Leben
|
||||
this.type = type; // 'dust', 'sparkle', 'explosion'
|
||||
|
||||
// Zufällige Geschwindigkeit
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
let speed = Math.random() * 2;
|
||||
|
||||
if (type === 'dust') {
|
||||
// Staub fliegt eher nach oben/hinten
|
||||
this.vx = -2 + Math.random();
|
||||
this.vy = -1 - Math.random();
|
||||
this.decay = 0.05; // Verschwindet schnell
|
||||
this.color = '#ddd';
|
||||
this.size = Math.random() * 4 + 2;
|
||||
}
|
||||
else if (type === 'sparkle') {
|
||||
// Münzen glitzern in alle Richtungen
|
||||
speed = Math.random() * 4 + 2;
|
||||
this.vx = Math.cos(angle) * speed;
|
||||
this.vy = Math.sin(angle) * speed;
|
||||
this.decay = 0.03;
|
||||
this.color = '#ffcc00';
|
||||
this.size = Math.random() * 3 + 1;
|
||||
}
|
||||
else if (type === 'explosion') {
|
||||
// Tod
|
||||
speed = Math.random() * 6 + 2;
|
||||
this.vx = Math.cos(angle) * speed;
|
||||
this.vy = Math.sin(angle) * speed;
|
||||
this.decay = 0.02;
|
||||
this.color = Math.random() > 0.5 ? '#ff4444' : '#ffaa00';
|
||||
this.size = Math.random() * 6 + 3;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
|
||||
// Physik
|
||||
if (this.type !== 'sparkle') this.vy += 0.2; // Schwerkraft für Staub/Explosion
|
||||
|
||||
// Reibung
|
||||
this.vx *= 0.95;
|
||||
this.vy *= 0.95;
|
||||
|
||||
this.life -= this.decay;
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
ctx.globalAlpha = this.life;
|
||||
ctx.fillStyle = this.color;
|
||||
|
||||
// Quadratische Partikel (schneller zu zeichnen)
|
||||
ctx.fillRect(this.x, this.y, this.size, this.size);
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
function spawnParticles(x, y, type, count = 5) {
|
||||
for(let i=0; i<count; i++) {
|
||||
particles.push(new Particle(x, y, type));
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticles() {
|
||||
// Rückwärts loopen zum sicheren Löschen
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
particles[i].update();
|
||||
if (particles[i].life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticles() {
|
||||
particles.forEach(p => p.draw(ctx));
|
||||
}
|
||||
@@ -1,113 +1,177 @@
|
||||
// ==========================================
|
||||
// RESIZE LOGIK (LETTERBOXING)
|
||||
// ==========================================
|
||||
function resize() {
|
||||
// 1. INTERNE SPIEL-AUFLÖSUNG ERZWINGEN
|
||||
// Das behebt den "Zoom/Nur Ecke sichtbar" Fehler
|
||||
// 1. Interne Auflösung fixieren
|
||||
canvas.width = GAME_WIDTH; // 800
|
||||
canvas.height = GAME_HEIGHT; // 400
|
||||
|
||||
// 2. Verfügbaren Platz im Browser berechnen (Minus etwas Rand)
|
||||
// 2. Verfügbaren Platz berechnen
|
||||
const windowWidth = window.innerWidth - 20;
|
||||
const windowHeight = window.innerHeight - 20;
|
||||
|
||||
const targetRatio = GAME_WIDTH / GAME_HEIGHT; // 2.0
|
||||
const targetRatio = GAME_WIDTH / GAME_HEIGHT;
|
||||
const windowRatio = windowWidth / windowHeight;
|
||||
|
||||
let finalWidth, finalHeight;
|
||||
|
||||
// 3. Letterboxing berechnen
|
||||
// 3. Skalierung berechnen (Aspect Ratio erhalten)
|
||||
if (windowRatio < targetRatio) {
|
||||
// Screen ist schmaler (z.B. Handy Portrait) -> Breite limitiert
|
||||
finalWidth = windowWidth;
|
||||
finalHeight = windowWidth / targetRatio;
|
||||
} else {
|
||||
// Screen ist breiter (z.B. Desktop) -> Höhe limitiert
|
||||
finalHeight = windowHeight;
|
||||
finalWidth = finalHeight * targetRatio;
|
||||
}
|
||||
|
||||
// 4. Größe auf den CONTAINER anwenden
|
||||
// 4. Container Größe setzen (Canvas füllt Container via CSS)
|
||||
if (container) {
|
||||
container.style.width = `${Math.floor(finalWidth)}px`;
|
||||
container.style.height = `${Math.floor(finalHeight)}px`;
|
||||
}
|
||||
|
||||
// Hinweis: Wir setzen KEINE style.width/height auf das Canvas Element selbst.
|
||||
// Das Canvas erbt "width: 100%; height: 100%" vom CSS und füllt den Container.
|
||||
}
|
||||
|
||||
// Event Listener
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// Einmal sofort ausführen
|
||||
resize();
|
||||
|
||||
|
||||
// --- DRAWING ---
|
||||
|
||||
function drawGame() {
|
||||
// ==========================================
|
||||
// DRAWING LOOP (MIT INTERPOLATION)
|
||||
// ==========================================
|
||||
// alpha (0.0 bis 1.0) gibt an, wie weit wir zeitlich zwischen zwei Physik-Ticks sind.
|
||||
function drawGame(alpha = 1.0) {
|
||||
// 1. Canvas leeren
|
||||
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
|
||||
// ===============================================
|
||||
// HINTERGRUND
|
||||
// ===============================================
|
||||
let currentBg = null;
|
||||
|
||||
if (bgSprites.length > 0) {
|
||||
// Wechselt alle 10.000 Punkte
|
||||
const changeInterval = 10000;
|
||||
|
||||
const currentRawIndex = Math.floor(score / changeInterval);
|
||||
|
||||
if (currentRawIndex > maxRawBgIndex) {
|
||||
maxRawBgIndex = currentRawIndex;
|
||||
}
|
||||
if (currentRawIndex > maxRawBgIndex) maxRawBgIndex = currentRawIndex;
|
||||
const bgIndex = maxRawBgIndex % bgSprites.length;
|
||||
|
||||
currentBg = bgSprites[bgIndex];
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (currentBg && currentBg.complete && currentBg.naturalHeight !== 0) {
|
||||
ctx.drawImage(currentBg, 0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
} else {
|
||||
// Fallback
|
||||
ctx.fillStyle = "#f0f0f0";
|
||||
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
}
|
||||
|
||||
// --- BODEN ---
|
||||
// Halb-transparent, damit er über dem Hintergrund liegt
|
||||
// ===============================================
|
||||
// BODEN
|
||||
// ===============================================
|
||||
ctx.fillStyle = "rgba(60, 60, 60, 0.8)";
|
||||
ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
|
||||
|
||||
// --- HINDERNISSE ---
|
||||
obstacles.forEach(obs => {
|
||||
const img = sprites[obs.def.id];
|
||||
// ===============================================
|
||||
// PLATTFORMEN (Interpoliert)
|
||||
// ===============================================
|
||||
platforms.forEach(p => {
|
||||
// Interpolierte X-Position
|
||||
const rX = (p.prevX !== undefined) ? lerp(p.prevX, p.x, alpha) : p.x;
|
||||
const rY = p.y;
|
||||
|
||||
// Prüfen ob Bild geladen ist
|
||||
if (img && img.complete && img.naturalHeight !== 0) {
|
||||
ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height);
|
||||
} else {
|
||||
// Fallback Farbe (Münzen Gold, Rest aus Config)
|
||||
if (obs.def.type === "coin") ctx.fillStyle = "gold";
|
||||
else ctx.fillStyle = obs.def.color || "red";
|
||||
|
||||
ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height);
|
||||
}
|
||||
|
||||
if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech);
|
||||
// Holz-Optik
|
||||
ctx.fillStyle = "#5D4037";
|
||||
ctx.fillRect(rX, rY, p.w, p.h);
|
||||
ctx.fillStyle = "#8D6E63";
|
||||
ctx.fillRect(rX, rY, p.w, 5); // Highlight oben
|
||||
});
|
||||
|
||||
// --- DEBUG RAHMEN (Server Hitboxen) ---
|
||||
// Grün im Spiel, Rot bei Tod
|
||||
if (DEBUG_SYNC == true) {
|
||||
ctx.strokeStyle = isGameOver ? "red" : "lime";
|
||||
ctx.lineWidth = 2;
|
||||
serverObstacles.forEach(srvObs => {
|
||||
ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h);
|
||||
});
|
||||
// ===============================================
|
||||
// HINDERNISSE (Interpoliert)
|
||||
// ===============================================
|
||||
obstacles.forEach(obs => {
|
||||
const def = obs.def || {};
|
||||
const img = sprites[def.id];
|
||||
|
||||
// Interpolation
|
||||
const rX = (obs.prevX !== undefined) ? lerp(obs.prevX, obs.x, alpha) : obs.x;
|
||||
const rY = obs.y;
|
||||
|
||||
// Hitbox Dimensionen
|
||||
const hbw = def.width || obs.w || 30;
|
||||
const hbh = def.height || obs.h || 30;
|
||||
|
||||
if (img && img.complete && img.naturalHeight !== 0) {
|
||||
// --- BILD VORHANDEN ---
|
||||
// Editor-Werte anwenden
|
||||
const scale = def.imgScale || 1.0;
|
||||
const offX = def.imgOffsetX || 0.0;
|
||||
const offY = def.imgOffsetY || 0.0;
|
||||
|
||||
// 1. Skalierte Größe
|
||||
const drawW = hbw * scale;
|
||||
const drawH = hbh * scale;
|
||||
|
||||
// 2. Positionierung (Zentriert & Unten Bündig zur Hitbox)
|
||||
const baseX = rX + (hbw - drawW) / 2;
|
||||
const baseY = rY + (hbh - drawH);
|
||||
|
||||
// 3. Zeichnen
|
||||
ctx.drawImage(img, baseX + offX, baseY + offY, drawW, drawH);
|
||||
|
||||
} else {
|
||||
// --- FALLBACK (KEIN BILD) ---
|
||||
// Magenta als Warnung, Gold für Coins
|
||||
let color = "#FF00FF";
|
||||
if (def.type === "coin") color = "gold";
|
||||
else if (def.color) color = def.color;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(rX, rY, hbw, hbh);
|
||||
|
||||
// Rahmen & Text
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(rX, rY, hbw, hbh);
|
||||
ctx.fillStyle = "white"; ctx.font = "bold 10px monospace";
|
||||
ctx.fillText(def.id || "?", rX, rY - 5);
|
||||
}
|
||||
|
||||
// --- DEBUG HITBOX (Client) ---
|
||||
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
|
||||
ctx.strokeStyle = "rgba(0,255,0,0.5)"; // Grün transparent
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(rX, rY, hbw, hbh);
|
||||
}
|
||||
|
||||
// Sprechblase
|
||||
if(obs.speech) drawSpeechBubble(rX, rY, obs.speech);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// DEBUG: SERVER STATE (Cyan)
|
||||
// ===============================================
|
||||
// Zeigt an, wo der Server die Objekte sieht (ohne Interpolation)
|
||||
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
|
||||
if (serverObstacles) {
|
||||
ctx.strokeStyle = "cyan";
|
||||
ctx.lineWidth = 1;
|
||||
serverObstacles.forEach(sObj => {
|
||||
// Wir müssen hier die Latenz-Korrektur aus network.js abziehen,
|
||||
// um zu sehen, wo network.js sie hingeschoben hat?
|
||||
// Nein, serverObstacles enthält die Rohdaten.
|
||||
// Wenn wir wissen wollen, wo der Server "jetzt" ist, müssten wir schätzen.
|
||||
// Wir zeichnen einfach Raw, das hinkt optisch meist hinterher.
|
||||
ctx.strokeRect(sObj.x, sObj.y, sObj.w, sObj.h);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// SPIELER (Interpoliert)
|
||||
// ===============================================
|
||||
// Interpolierte Y-Position
|
||||
let rPlayerY = lerp(player.prevY !== undefined ? player.prevY : player.y, player.y, alpha);
|
||||
|
||||
// --- SPIELER ---
|
||||
// Y-Position und Höhe anpassen für Ducken
|
||||
const drawY = isCrouching ? player.y + 25 : player.y;
|
||||
// Ducken Anpassung
|
||||
const drawY = isCrouching ? rPlayerY + 25 : rPlayerY;
|
||||
const drawH = isCrouching ? 25 : 50;
|
||||
|
||||
if (playerSprite.complete && playerSprite.naturalHeight !== 0) {
|
||||
@@ -117,7 +181,16 @@ function drawGame() {
|
||||
ctx.fillRect(player.x, drawY, player.w, drawH);
|
||||
}
|
||||
|
||||
// --- HUD (Powerup Status oben links) ---
|
||||
// ===============================================
|
||||
// PARTIKEL (Visuelle Effekte)
|
||||
// ===============================================
|
||||
if (typeof drawParticles === 'function') {
|
||||
drawParticles();
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// HUD (Statusanzeige)
|
||||
// ===============================================
|
||||
if (isGameRunning && !isGameOver) {
|
||||
ctx.fillStyle = "black";
|
||||
ctx.font = "bold 10px monospace";
|
||||
@@ -128,30 +201,29 @@ function drawGame() {
|
||||
if(hasBat) statusText += `⚾ BAT `;
|
||||
if(bootTicks > 0) statusText += `👟 ${(bootTicks/60).toFixed(1)}s`;
|
||||
|
||||
// Drift Info (nur wenn Objekte da sind)
|
||||
if (DEBUG_SYNC == true && length > 0 && serverObstacles.length > 0) {
|
||||
const drift = Math.abs(obstacles[0].x - serverObstacles[0].x).toFixed(1);
|
||||
statusText += ` | Drift: ${drift}px`; // Einkommentieren für Debugging
|
||||
}
|
||||
|
||||
if(statusText !== "") {
|
||||
ctx.fillText(statusText, 10, 40);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GAME OVER OVERLAY ---
|
||||
// ===============================================
|
||||
// GAME OVER OVERLAY
|
||||
// ===============================================
|
||||
if (isGameOver) {
|
||||
// Dunkler Schleier über alles
|
||||
ctx.fillStyle = "rgba(0,0,0,0.7)";
|
||||
ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
// Sprechblasen Helper
|
||||
// Helper: Sprechblase zeichnen
|
||||
function drawSpeechBubble(x, y, text) {
|
||||
const bX = x-20; const bY = y-40; const bW = 120; const bH = 30;
|
||||
ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH);
|
||||
ctx.strokeRect(bX,bY,bW,bH);
|
||||
ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center";
|
||||
ctx.fillText(text, bX+bW/2, bY+20);
|
||||
const bX = x - 20;
|
||||
const bY = y - 40;
|
||||
const bW = 120;
|
||||
const bH = 30;
|
||||
|
||||
ctx.fillStyle = "white"; ctx.fillRect(bX, bY, bW, bH);
|
||||
ctx.strokeStyle = "black"; ctx.lineWidth = 1; ctx.strokeRect(bX, bY, bW, bH);
|
||||
ctx.fillStyle = "black"; ctx.font = "10px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText(text, bX + bW/2, bY + 20);
|
||||
}
|
||||
@@ -1,51 +1,79 @@
|
||||
// Globale Status-Variablen
|
||||
let gameConfig = null;
|
||||
let isLoaded = false;
|
||||
let isGameRunning = false;
|
||||
let isGameOver = false;
|
||||
let sessionID = null;
|
||||
// ==========================================
|
||||
// GLOBALE STATUS VARIABLEN
|
||||
// ==========================================
|
||||
|
||||
let rng = null;
|
||||
let score = 0;
|
||||
let currentTick = 0;
|
||||
let lastSentTick = 0;
|
||||
let inputLog = [];
|
||||
let isCrouching = false;
|
||||
// --- Konfiguration & Flags ---
|
||||
let gameConfig = null; // Wird von /api/config geladen
|
||||
let isLoaded = false; // Sind Assets geladen?
|
||||
let isGameRunning = false; // Läuft der Game Loop?
|
||||
let isGameOver = false; // Ist der Spieler tot?
|
||||
let sessionID = null; // UUID der aktuellen Session
|
||||
|
||||
// Powerups Client State
|
||||
// --- NETZWERK & STREAMING (NEU) ---
|
||||
let socket = null; // Die WebSocket Verbindung
|
||||
let obstacleBuffer = []; // Warteschlange für kommende Hindernisse
|
||||
let platformBuffer = []; // Warteschlange für kommende Plattformen
|
||||
|
||||
// --- SPIELZUSTAND ---
|
||||
let score = 0; // Aktueller Punktestand (vom Server diktiert)
|
||||
let currentTick = 0; // Zeit-Einheit des Spiels
|
||||
|
||||
// --- POWERUPS (Client Visuals) ---
|
||||
let godModeLives = 0;
|
||||
let hasBat = false;
|
||||
let bootTicks = 0;
|
||||
|
||||
// Hintergrund
|
||||
let currentBgIndex = 0;
|
||||
let maxRawBgIndex = 0;
|
||||
// --- HINTERGRUND ---
|
||||
let maxRawBgIndex = 0; // Welcher Hintergrund wird gezeigt?
|
||||
|
||||
// Tick Time
|
||||
// --- GAME LOOP TIMING ---
|
||||
let lastTime = 0;
|
||||
let accumulator = 0;
|
||||
let lastPowerupTick = -9999;
|
||||
let nextSpawnTick = 0;
|
||||
|
||||
// Grafiken
|
||||
let sprites = {};
|
||||
// --- GRAFIKEN ---
|
||||
let sprites = {}; // Cache für Hindernis-Bilder
|
||||
let playerSprite = new Image();
|
||||
let bgSprite = new Image();
|
||||
let bgSprites = [];
|
||||
// Spiel-Objekte
|
||||
let bgSprites = []; // Array der Hintergrund-Bilder
|
||||
|
||||
// --- ENTITIES (Render-Listen) ---
|
||||
let player = {
|
||||
x: 50, y: 300, w: 30, h: 50, color: "red",
|
||||
vy: 0, grounded: false
|
||||
x: 50,
|
||||
y: 300,
|
||||
w: 30,
|
||||
h: 50,
|
||||
color: "red",
|
||||
vy: 0,
|
||||
grounded: false,
|
||||
prevY: 300
|
||||
};
|
||||
let particles = [];
|
||||
|
||||
|
||||
// Diese Listen werden von logic.js aus dem Buffer gefüllt und von render.js gezeichnet
|
||||
let obstacles = [];
|
||||
let serverObstacles = [];
|
||||
let platforms = [];
|
||||
|
||||
// HTML Elemente (Caching)
|
||||
// Debug-Daten (optional, falls der Server Debug-Infos schickt)
|
||||
let serverObstacles = [];
|
||||
let serverPlatforms = [];
|
||||
|
||||
let currentLatencyMs = 0; // Aktuelle Latenz in Millisekunden
|
||||
let pingInterval = null; // Timer für den Ping
|
||||
|
||||
// --- INPUT STATE ---
|
||||
let isCrouching = false;
|
||||
|
||||
// ==========================================
|
||||
// HTML ELEMENTE (Caching)
|
||||
// ==========================================
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const container = document.getElementById('game-container');
|
||||
|
||||
// UI Elemente
|
||||
const startScreen = document.getElementById('startScreen');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const loadingText = document.getElementById('loadingText');
|
||||
const gameOverScreen = document.getElementById('gameOverScreen');
|
||||
const gameOverScreen = document.getElementById('gameOverScreen');
|
||||
const scoreDisplay = document.getElementById('score');
|
||||
const highscoreDisplay = document.getElementById('localHighscore');
|
||||
@@ -362,4 +362,32 @@ input {
|
||||
@media screen and (orientation: portrait) {
|
||||
#rotate-overlay { display: flex; }
|
||||
#game-container { display: none !important; }
|
||||
}
|
||||
|
||||
/* ... bestehende Styles ... */
|
||||
|
||||
#mute-btn {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 100; /* Über allem */
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 2px solid #555;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0; /* Override default button margin */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
#mute-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: white;
|
||||
}
|
||||