Files
yourpart3/frontend/src/views/minigames/TaxiGame.vue
Torsten Schulz (local) bc3c765948 Änderung: Erweiterung der Fahrzeug-Logik und Verbesserung der Abbiege- sowie Spawn-Mechanik im Taxi-Spiel
Änderungen:
- Anpassung der Fahrzeug-Spawn-Wahrscheinlichkeit auf 100% für Debugging-Zwecke.
- Implementierung neuer Methoden zur Überprüfung, ob Fahrzeuge an Kreuzungen abbiegen sollten, und zur Ermittlung zufälliger Zielrichtungen basierend auf dem aktuellen Tile-Typ.
- Verbesserung der Fahrzeugbewegung und Kollisionserkennung durch präzisere Logik zur Überprüfung der Befahrbarkeit von Positionen.
- Erweiterung der Debugging-Ausgaben zur besseren Nachverfolgbarkeit von Fahrzeugbewegungen und -zuständen.

Diese Anpassungen optimieren die Fahrzeug-Interaktion und verbessern die Spielmechanik durch präzisere Logik und erweiterte Debugging-Funktionen.
2025-10-20 15:15:18 +02:00

5234 lines
189 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="contenthidden">
<div class="contentscroll">
<!-- Spiel-Titel -->
<div class="game-title">
<h1>{{ $t('minigames.taxi.title') }}</h1>
<p>{{ $t('minigames.taxi.description') }}</p>
</div>
<!-- Spiel-Layout -->
<div class="game-layout">
<!-- Spielbrett (links) -->
<div class="game-board-section">
<!-- Spielbereich mit Canvas und Legende -->
<div class="game-area">
<!-- Legende (links) -->
<div class="controls-legend">
<h3>Steuerung</h3>
<div class="legend-grid">
<div class="legend-item">
<span class="legend-key"> W</span>
<span class="legend-text">Gas geben</span>
</div>
<div class="legend-item">
<span class="legend-key"> X</span>
<span class="legend-text">Bremsen</span>
</div>
<div class="legend-item">
<span class="legend-key"> D</span>
<span class="legend-text">Rechts lenken</span>
</div>
<div class="legend-item">
<span class="legend-key"> A</span>
<span class="legend-text">Links lenken</span>
</div>
</div>
<!-- Ziele -->
<div class="game-objectives">
<h4>Ziele</h4>
<ul>
<li>Vermeide Kollisionen mit anderen Fahrzeugen</li>
</ul>
</div>
<!-- Straßenname-Legende -->
<div class="game-objectives" v-if="streetLegend.length">
<h4>Straßennamen</h4>
<ul>
<li
v-for="item in streetLegend"
:key="item.num"
class="legend-street-item"
:class="{ selected: selectedStreetName === item.name }"
@click="onSelectStreet(item.name)"
>
{{ item.num }}: {{ item.name }}
</li>
</ul>
</div>
</div>
<!-- Spiel-Canvas mit Tacho -->
<div class="game-canvas-section">
<!-- Tacho-Display -->
<div class="tacho-display">
<div class="tacho-speed">
<span class="lives" title="Verbleibende Fahrzeuge"> {{ vehicleCount }}</span>
<span class="fuel" title="Treibstoff"> {{ Math.round(fuel) }}%</span>
<span class="score" title="Punkte"> {{ score }}</span>
<span class="speed-violations" title="Geschwindigkeitsverstöße">📷 {{ speedViolations }}</span>
<span class="redlight-counter" title="Rote Ampeln überfahren">
<span class="redlight-icon">🚦</span>
<span class="redlight-value">{{ redLightViolations }}</span>
</span>
<span class="speed-group">
<span class="tacho-icon"></span>
<span class="speed-value">{{ taxi.speed * 5 }}</span>
<span class="speed-unit">km/h</span>
</span>
</div>
</div>
<!-- Pause-Anzeige -->
<div v-if="showPauseOverlay" class="pause-overlay">
<div class="pause-message">
<h2>{{ $t('minigames.taxi.paused') }}</h2>
<button @click="togglePause" class="resume-button">
{{ $t('minigames.taxi.resume') }}
</button>
</div>
</div>
<!-- Canvas und Highscore Container -->
<div class="game-canvas-section">
<!-- Canvas (immer sichtbar) -->
<div class="game-canvas-container">
<canvas
ref="gameCanvas"
width="500"
height="500"
class="game-canvas"
@click="handleCanvasClick"
@keydown="handleKeyDown"
tabindex="0"
></canvas>
</div>
<!-- Highscore-Anzeige (als Overlay über dem Canvas) -->
<div v-if="showHighscore" class="highscore-overlay">
<div class="highscore-header">
<h2>🏆 Highscore</h2>
<div class="highscore-subtitle">Top 20 Spieler</div>
</div>
<div class="highscore-list">
<div v-if="loadingHighscore" class="loading-message">
Lade Highscore...
</div>
<div v-else-if="highscoreList.length === 0" class="no-highscore">
Noch keine Highscores vorhanden
</div>
<div v-else class="highscore-table">
<div
v-for="(entry, index) in highscoreList"
:key="index"
class="highscore-entry"
:class="{ 'current-player': entry.isCurrentPlayer }"
>
<div class="highscore-rank">{{ entry.rank }}</div>
<div class="highscore-name">{{ entry.nickname }}</div>
<div class="highscore-points">{{ entry.points }} Pkt</div>
</div>
<div v-if="showCurrentPlayerBelow" class="highscore-separator">...</div>
<div
v-if="currentPlayerEntry && showCurrentPlayerBelow"
class="highscore-entry current-player"
>
<div class="highscore-rank">{{ currentPlayerEntry.rank }}</div>
<div class="highscore-name">{{ currentPlayerEntry.nickname }}</div>
<div class="highscore-points">{{ currentPlayerEntry.points }} Pkt</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Spiel-Controls -->
<div class="game-controls">
<button @click="togglePause" class="control-button">
{{ isPaused ? $t('minigames.taxi.resume') : $t('minigames.taxi.pause') }}
</button>
<button @click="restartLevel" class="control-button">
{{ $t('minigames.taxi.restartLevel') }}
</button>
<button @click="toggleHighscore" class="control-button">
{{ showHighscore ? 'Zurück zum Spiel' : 'Highscore' }}
</button>
</div>
</div>
<!-- Sidebar (rechts) -->
<div class="sidebar-section">
<!-- Minimap -->
<div class="minimap-card">
<div class="minimap-header">
<h3 class="minimap-title">Minimap</h3>
<div class="map-selector">
<select v-model="selectedMapId" @change="onMapChange" class="map-select">
<option
v-for="map in maps"
:key="map.id"
:value="map.id"
>
{{ map.name }}
</option>
</select>
</div>
</div>
<div class="minimap-container">
<canvas
ref="minimapCanvas"
width="200"
height="150"
class="minimap-canvas"
></canvas>
</div>
</div>
<!-- Geladene Passagiere -->
<div class="loaded-passengers-card">
<div class="loaded-passengers-header">
<h3 class="loaded-passengers-title">Geladene Passagiere</h3>
</div>
<div class="loaded-passengers-content">
<div v-if="loadedPassengersList.length === 0" class="no-passengers">
Keine Passagiere im Taxi
</div>
<table v-else class="passengers-table">
<thead>
<tr>
<th>Name</th>
<th>Ziel</th>
<th>Bonus</th>
<th>Zeit</th>
</tr>
</thead>
<tbody>
<tr
v-for="(passenger, index) in loadedPassengersList"
:key="index"
class="passenger-row"
:class="{ 'time-warning': passenger.timeLeft <= 10, 'time-critical': passenger.timeLeft <= 5 }"
>
<td class="passenger-name-cell">{{ passenger.name }}</td>
<td class="passenger-destination-cell">{{ passenger.destination.location }}</td>
<td class="passenger-bonus-cell">{{ passenger.bonusData ? passenger.bonusData.bonus : 0 }}</td>
<td class="passenger-time-cell" :class="{ 'time-warning': passenger.timeLeft <= 10, 'time-critical': passenger.timeLeft <= 5 }">
{{ passenger.timeLeft }}s
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Wartende Passagiere -->
<div class="waiting-passengers-card">
<div class="waiting-passengers-header">
<h3 class="waiting-passengers-title">Wartende Passagiere</h3>
</div>
<div class="waiting-passengers-list">
<div v-if="waitingPassengersList.length === 0" class="no-passengers">
Keine wartenden Passagiere
</div>
<table v-else>
<tr v-for="(passenger, index) in waitingPassengersList"
:key="index" >
<td><b>{{ passenger.name }}</b><span v-if="getPassengerTimeLeft(passenger) <= 5" class="passenger-timer"> ({{ getPassengerTimeLeft(passenger) }}s)</span></td>
<td>{{ passenger.location }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import streetCoordinates from '../../utils/streetCoordinates.js';
import { MotorSound, NoiseGenerator } from '../../assets/motorSound.js';
import apiClient from '../../utils/axios.js';
export default {
name: 'TaxiGame',
components: {
},
data() {
return {
isPaused: false,
showPauseOverlay: false,
score: 0,
money: 0,
passengersDelivered: 0,
waitingPassengersList: [],
loadedPassengersList: [], // Aktuell geladene Passagiere im Taxi
occupiedHouses: new Set(), // Verhindert doppelte Belegung von Häusern
lastPassengerGeneration: 0,
debugCollisionShown: false, // Flag für einmalige Debug-Ausgabe
passengerGenerationInterval: 0,
// Timeout-Referenzen für Cleanup
passengerGenerationTimeout: null,
crashDialogTimeout: null,
bonusMultiplier: 15, // Bonus pro Tile
timePerTile: 8, // Sekunden pro Tile
fuel: 100,
isRefueling: false,
refuelTimer: null,
currentLevel: 1,
gameRunning: false,
crashes: 0,
gameLoop: null,
lastSpeedChange: 0,
speedChangeInterval: 100, // 200ms = 0.2 Sekunden
lastSteerChange: 0,
steeringBaseInterval: 300, // Basisintervall in ms bei sehr niedriger Geschwindigkeit
canvas: null,
ctx: null,
minimapCanvas: null,
minimapCtx: null,
taxi: {
x: 250, // Mitte des 400x400px Tiles
y: 250, // Mitte des 400x400px Tiles
width: 30, // Skalierte Breite (50px Höhe * 297/506)
height: 50, // 50px Höhe
angle: 0, // Fahrtrichtung
imageAngle: Math.PI / 2, // Bildrotation (90° nach rechts)
speed: 0,
maxSpeed: 24, // 24 = 120 km/h (5er-Schritte: 0, 5, 10, ..., 120)
image: null // Taxi-Bild
},
currentTile: {
row: 0,
col: 0
},
tiles: {
size: 500, // 400x400px pro Tile
images: {}
},
houseImage: null,
passengerImages: {}, // Speichert geladene Passagier-Bilder
trafficLightStates: {},
lastTrafficLightTick: 0,
redLightViolations: 0,
redLightLastCount: {},
lastViolationSound: 0,
lastMinimapDraw: 0,
minimapDrawInterval: 120,
lastPassengerTimerUpdate: 0, // Throttling für Passagier-Timer-Updates
radarImg: null,
activeRadar: false,
radarAtTopEdge: true, // legacy flag (nicht mehr genutzt)
radarTicketShown: false,
speedViolations: 0,
radarAxis: null, // 'H' (horizontale Messlinie: y=konstant) oder 'V' (vertikale Messlinie: x=konstant)
radarLinePos: 0,
vehicleCount: 5,
redLightSincePenalty: 0,
gameStartTime: null, // Zeitstempel wann das Spiel gestartet wurde
maps: [], // Geladene Maps aus der Datenbank
currentMap: null, // Aktuell verwendete Map
selectedMapId: null, // ID der ausgewählten Map
passengers: [],
destinations: [],
obstacles: [],
keys: {},
motorSound: null,
audioContext: null,
audioUnlockHandler: null,
selectedStreetName: null
,motorStopTimeout: null
,houseNumbers: {}
,prevTaxiX: 250
,prevTaxiY: 250
,skipRedLightOneFrame: false
,gasStations: [] // Tankstellen im Spiel
,showHighscore: false // Highscore-Anzeige aktiv
,highscoreList: [] // Liste der Highscore-Einträge
,loadingHighscore: false // Lade-Status für Highscore
,currentPlayerEntry: null // Eintrag des aktuellen Spielers
,showCurrentPlayerBelow: false // Zeige aktuellen Spieler nach Platz 20
// Autos-System
,cars: [] // Liste der aktiven Autos
,carImage: null // Geladenes Auto-Bild
,lastCarGeneration: 0 // Zeitstempel der letzten Autos-Generierung
,carGenerationInterval: 1000 // Autos-Generierung alle 1000ms (1 Sekunde) prüfen
,carSpawnProbability: 1.0 // 100% Wahrscheinlichkeit pro Sekunde (temporär für Debugging)
}
},
computed: {
toggleIcon() {
return this.isStatsExpanded ? '' : '+';
},
streetLegend() {
// Sammle alle Straßennamen aus currentMap.tileStreets und vergebe laufende Nummern
try {
const legend = [];
if (!this.currentMap || !Array.isArray(this.currentMap.tileStreets)) return legend;
const nameToNum = new Map();
let counter = 1;
for (const ts of this.currentMap.tileStreets) {
if (ts.streetNameH && ts.streetNameH.name) {
const n = ts.streetNameH.name;
if (!nameToNum.has(n)) nameToNum.set(n, counter++);
}
if (ts.streetNameV && ts.streetNameV.name) {
const n = ts.streetNameV.name;
if (!nameToNum.has(n)) nameToNum.set(n, counter++);
}
}
// Sortiert nach zugewiesener Nummer
const entries = Array.from(nameToNum.entries()).sort((a,b) => a[1]-b[1]);
for (const [name, num] of entries) {
legend.push({ num, name });
}
return legend;
} catch (e) {
return [];
}
}
},
async mounted() {
this.initializeGame();
this.initializeMinimap();
this.loadTiles();
this.loadTaxiImage();
this.loadHouseImage();
this.loadPassengerImages();
this.loadCarImage();
this.loadMaps();
this.setupEventListeners();
await this.initializeMotorSound();
this.setupAudioUnlockHandlers();
this.lastTrafficLightTick = Date.now();
// Memory-Cleanup seltener ausführen, um Render-Glitches zu vermeiden
this.memoryCleanupInterval = setInterval(() => {
this.performMemoryCleanup();
}, 2 * 60 * 1000); // alle 2 Minuten
},
beforeUnmount() {
console.log('🚪 Component unmounting, cleaning up...');
this.cleanup();
},
methods: {
// Ampelschaltung: sekündliche Phasen-Updates; pro Tile ein State
updateTrafficLights() {
const now = Date.now();
if (now - this.lastTrafficLightTick < 1000) return;
this.lastTrafficLightTick = now;
if (!this.currentMap || !Array.isArray(this.currentMap.tiles)) return;
const tiles = this.currentMap.tiles.filter(t => t.trafficLight || (t.meta && t.meta.trafficLight));
for (const t of tiles) {
const key = `${t.x},${t.y}`;
let st = this.trafficLightStates[key];
if (!st) {
// initialisieren: Phase starten, zufällige Grün/Rot-Dauern 5..15s
const r = (min,max)=> Math.floor(min + Math.random()*(max-min+1));
st = {
phase: 0, // 0:Hgrün/Vrot, 1:Hgelb/Vrotgelb, 2:Hrot/Vgrün, 3:Hrotgelb/Vgelb
remaining: r(5,15), // Sekunden bis zur nächsten Umschaltung
hDur: r(5,15), // Dauer H-grün (Phase 0)
vDur: r(5,15) // Dauer V-grün (Phase 2)
};
this.trafficLightStates[key] = st;
}
st.remaining -= 1;
if (st.remaining <= 0) {
// Phase vorwärts schalten, Zwischenphasen 1s
if (st.phase === 0) { st.phase = 1; st.remaining = 1; }
else if (st.phase === 1) { st.phase = 2; st.remaining = st.vDur; }
else if (st.phase === 2) { st.phase = 3; st.remaining = 1; }
else { st.phase = 0; st.remaining = st.hDur; }
}
}
},
// Hilfsfunktion: Liefert laufende Nummer für gegebenen Straßennamen
getStreetNumberByName(name) {
const item = this.streetLegend.find(it => it.name === name);
return item ? item.num : null;
},
// Prüfe, ob das Auto am Abbiegepunkt (Tile-Zentrumslinien) ist
// Gibt bevorzugte Zielrichtung zurück (string) oder null, wenn nicht abbiegen
shouldCarTurnAtIntersection(car) {
// Fahrzeugmitte verwenden
const cx = car.x + car.width / 2;
const cy = car.y + car.height / 2;
const rx = cx / this.tiles.size;
const ry = cy / this.tiles.size;
const tileType = this.mapTileTypeToStreetCoordinates(this.getCurrentTileType());
// Toleranzfenster um die Mittellinien, damit der Trigger nicht frame-genau sein muss
const tol = 0.01; // ~5px bei 500px Tilegröße
const nearYCenter = ry > 0.5 - tol && ry < 0.5 + tol;
const nearXCenter = rx > 0.5 - tol && rx < 0.5 + tol;
switch (tileType) {
case 'cornerBottomRight':
// unten -> rechts
if (car.direction === 'up' && nearYCenter) return 'right';
if (car.direction === 'left' && nearXCenter) return 'down';
return null;
case 'cornerBottomLeft':
// unten -> links
if (car.direction === 'up' && nearYCenter) return 'left';
if (car.direction === 'right' && nearXCenter) return 'down';
return null;
case 'cornerTopRight':
// oben -> rechts
if (car.direction === 'down' && nearYCenter) return 'right';
if (car.direction === 'left' && nearXCenter) return 'up';
return null;
case 'cornerTopLeft':
// oben -> links
if (car.direction === 'down' && nearYCenter) return 'left';
if (car.direction === 'right' && nearXCenter) return 'up';
return null;
default:
// Kreuzung: bei Zentrumslinien abbiegen erlaubt Richtung später wählen
if (nearXCenter || nearYCenter) return this.getRandomTargetDirection(car.direction);
return null;
}
},
// Liefert eine sinnvolle Zielrichtung je Tile-Typ und aktueller Richtung (keine Kehrtwende)
getRandomTargetDirection(currentDirection) {
const tileType = this.mapTileTypeToStreetCoordinates(this.getCurrentTileType());
const options = [];
switch (tileType) {
case 'cornerBottomRight':
// unten -> rechts: von 'up' nach 'right', von 'left' nach 'down'
if (currentDirection === 'up') options.push('right');
if (currentDirection === 'left') options.push('down');
break;
case 'cornerBottomLeft':
// unten -> links: von 'up' nach 'left', von 'right' nach 'down'
if (currentDirection === 'up') options.push('left');
if (currentDirection === 'right') options.push('down');
break;
case 'cornerTopRight':
// oben -> rechts: von 'down' nach 'right', von 'left' nach 'up'
if (currentDirection === 'down') options.push('right');
if (currentDirection === 'left') options.push('up');
break;
case 'cornerTopLeft':
// oben -> links: von 'down' nach 'left', von 'right' nach 'up'
if (currentDirection === 'down') options.push('left');
if (currentDirection === 'right') options.push('up');
break;
case 'cross':
// Kreuzung: links/rechts bei vertikaler Bewegung, hoch/runter bei horizontaler Bewegung
if (currentDirection === 'up' || currentDirection === 'down') options.push('left', 'right');
if (currentDirection === 'left' || currentDirection === 'right') options.push('up', 'down');
break;
default:
// gerade Strecken: weiterfahren
options.push(currentDirection);
}
// Fallback
if (options.length === 0) options.push(currentDirection);
return options[Math.floor(Math.random() * options.length)];
},
// Zeichnet die Straßen-Nr. auf die Minimap, je nach Tile-Typ und Position (pro Name nur einmal)
drawStreetNumbersOnMinimap(ctx, x, y, size, tileType, col, row, drawnNames) {
if (!this.currentMap || !Array.isArray(this.currentMap.tileStreets)) return;
// Finde etwaige StreetName-Einträge für dieses Tile
const entry = this.currentMap.tileStreets.find(ts => ts.x === col && ts.y === row);
if (!entry) return;
ctx.save();
const baseFontSize = Math.max(9, Math.floor(size * 0.22) - 1);
ctx.font = `${baseFontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const left = x + size * 0.2;
const right = x + size * 0.8;
const top = y + size * 0.2;
const bottom = y + size * 0.8;
const hName = entry.streetNameH?.name || null;
const vName = entry.streetNameV?.name || null;
const hNum = hName ? this.getStreetNumberByName(hName) : null;
const vNum = vName ? this.getStreetNumberByName(vName) : null;
const t = tileType;
// Regeln gemäß Vorgabe
// Horizontal linke Seite:
const hLeftTiles = new Set(['cornerbottomleft','cornertopleft','horizontal','tdown','tup','cross','fuelhorizontal','tleft']);
// Horizontal rechte Seite:
const hRightTiles = new Set(['cornerbottomright','cornertopright','tright']);
// Vertical oben:
const vTopTiles = new Set(['cornertopleft','cornertopright','vertical','tup','tleft','tright','cross','fuelvertical']);
// Vertical unten:
const vBottomTiles = new Set(['cornerbottomleft','cornerbottomright','tdown']);
// Zusätzliche Kanten-spezifische Regeln aus der Einleitung
// cornerbottomright: horizontal rechts, vertical unten
// cornerbottomleft: horizontal links, vertical unten
// ... ist bereits über Sets abgedeckt
if (hNum !== null && hName && !drawnNames.has(hName)) {
const hx = hRightTiles.has(t) ? right : left;
const hy = y + size * 0.5 + 1; // 1px nach unten
const isSelH = (this.selectedStreetName && this.selectedStreetName === hName);
ctx.fillStyle = isSelH ? '#ffffff' : '#111';
ctx.font = isSelH ? `bold ${baseFontSize}px sans-serif` : `${baseFontSize}px sans-serif`;
ctx.fillText(String(hNum), hx, hy);
drawnNames.add(hName);
}
if (vNum !== null && vName && !drawnNames.has(vName)) {
const vx = x + size * 0.5;
const vy = vTopTiles.has(t) ? top : (vBottomTiles.has(t) ? bottom : top);
const isSelV = (this.selectedStreetName && this.selectedStreetName === vName);
ctx.fillStyle = isSelV ? '#ffffff' : '#111';
ctx.font = isSelV ? `bold ${baseFontSize}px sans-serif` : `${baseFontSize}px sans-serif`;
ctx.fillText(String(vNum), vx, vy);
drawnNames.add(vName);
}
ctx.restore();
},
// Mapping zwischen Spiel-Tile-Namen (lowercase) und streetCoordinates.json (camelCase)
mapTileTypeToStreetCoordinates(tileType) {
const mapping = {
'cornertopleft': 'cornerTopLeft',
'cornertopright': 'cornerTopRight',
'cornerbottomleft': 'cornerBottomLeft',
'cornerbottomright': 'cornerBottomRight',
'horizontal': 'horizontal',
'vertical': 'vertical',
'cross': 'cross',
'fuelhorizontal': 'fuelHorizontal',
'fuelvertical': 'fuelVertical',
'tleft': 'tLeft',
'tright': 'tRight',
'tup': 'tUp',
'tdown': 'tDown'
};
return mapping[tileType] || tileType;
},
initializeGame() {
this.canvas = this.$refs.gameCanvas;
this.ctx = this.canvas.getContext('2d');
this.canvas.focus();
// Taxi in die Mitte des 400x400px Tiles positionieren
this.taxi.x = 250 - this.taxi.width/2; // Zentriert in der Mitte
this.taxi.y = 250 - this.taxi.height/2; // Zentriert in der Mitte
this.taxi.angle = 0; // Fahrtrichtung nach oben
this.taxi.speed = 0; // Geschwindigkeit zurücksetzen
// Vorherige Position und Skip-Flag setzen, damit erste Prüfung nicht triggert
this.prevTaxiX = this.taxi.x;
this.prevTaxiY = this.taxi.y;
this.skipRedLightOneFrame = true;
// Aktuelle Tile-Position setzen
this.currentTile.row = 0;
this.currentTile.col = 0;
this.generateLevel();
this.initializePassengerGeneration();
this.startGame();
},
updateCanvasSize() {
// Canvas ist immer 400x400px für ein einzelnes Tile
this.canvas.width = 500;
this.canvas.height = 500;
},
updateCurrentTile() {
// Wechsel auf benachbarte Tiles, sobald der Rand erreicht wird
if (!this.currentMap || !this.currentMap.mapData) return;
const mapData = this.currentMap.mapData;
const rows = mapData.length;
const cols = mapData[0] ? mapData[0].length : 0;
const tileSize = this.tiles.size; // 500px
let newRow = this.currentTile.row;
let newCol = this.currentTile.col;
let moved = false;
let moveDir = null; // 'right'|'left'|'down'|'up'
// Nach rechts: sobald rechte Kante den Rand erreicht (>=)
if (this.taxi.x + this.taxi.width >= tileSize && newCol < cols - 1) {
this.taxi.x = 0; // direkt an linken Rand des neuen Tiles
newCol += 1;
moved = true;
moveDir = 'right';
}
// Nach links: sobald linke Kante den Rand erreicht (<= 0)
if (!moved && this.taxi.x <= 0 && newCol > 0) {
this.taxi.x = tileSize - this.taxi.width; // direkt an rechten Rand des neuen Tiles
newCol -= 1;
moved = true;
moveDir = 'left';
}
// Nach unten: sobald untere Kante den Rand erreicht (>=)
if (!moved && this.taxi.y + this.taxi.height >= tileSize && newRow < rows - 1) {
this.taxi.y = 0; // direkt an oberen Rand des neuen Tiles
newRow += 1;
moved = true;
moveDir = 'down';
}
// Nach oben: sobald obere Kante den Rand erreicht (<= 0)
if (!moved && this.taxi.y <= 0 && newRow > 0) {
this.taxi.y = tileSize - this.taxi.height; // direkt an unteren Rand des neuen Tiles
newRow -= 1;
moved = true;
moveDir = 'up';
}
// Falls Wechsel möglich war, übernehme neue Tile-Position
if (moved) {
this.currentTile.row = newRow;
this.currentTile.col = newCol;
// Nach Tile-Wechsel: eine Frame lang Rotlicht-Prüfung aussetzen
// und prev-Position auf aktuelle setzen, um false positives zu vermeiden
this.prevTaxiX = this.taxi.x;
this.prevTaxiY = this.taxi.y;
this.skipRedLightOneFrame = true;
// Radar mit 12% Wahrscheinlichkeit aktivieren beim Betreten des neuen Tiles
this.activeRadar = Math.random() < 0.12;
// Achse und Position festlegen: Messung findet bei 150px vom Rand statt
if (this.activeRadar) {
const size = this.tiles.size;
if (moveDir === 'right') { this.radarAxis = 'V'; this.radarLinePos = 150; }
else if (moveDir === 'left') { this.radarAxis = 'V'; this.radarLinePos = size - 150; }
else if (moveDir === 'down') { this.radarAxis = 'H'; this.radarLinePos = 150; }
else if (moveDir === 'up') { this.radarAxis = 'H'; this.radarLinePos = size - 150; }
else { this.radarAxis = 'H'; this.radarLinePos = 150; }
} else {
this.radarAxis = null;
}
this.radarTicketShown = false;
return;
}
// An den äußeren Grenzen der Map: innerhalb des aktuellen Tiles halten
this.taxi.x = Math.max(0, Math.min(tileSize - this.taxi.width, this.taxi.x));
this.taxi.y = Math.max(0, Math.min(tileSize - this.taxi.height, this.taxi.y));
},
setupEventListeners() {
// Event-Listener auf Document registrieren (Canvas bleibt immer sichtbar)
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
},
setupAudioUnlockHandlers() {
this.audioUnlockHandler = async () => {
await this.initAudioOnUserInteraction();
if (this.audioContext && this.audioContext.state === 'suspended') {
await this.audioContext.resume().catch(() => {});
}
document.removeEventListener('pointerdown', this.audioUnlockHandler, { capture: true });
document.removeEventListener('touchstart', this.audioUnlockHandler, { capture: true });
document.removeEventListener('keydown', this.audioUnlockHandler, { capture: true });
this.audioUnlockHandler = null;
};
document.addEventListener('pointerdown', this.audioUnlockHandler, { capture: true, passive: true });
document.addEventListener('touchstart', this.audioUnlockHandler, { capture: true, passive: true });
document.addEventListener('keydown', this.audioUnlockHandler, { capture: true });
},
async initializeMotorSound() {
// AudioContext wird erst bei erster Benutzerinteraktion erstellt
},
async initAudioOnUserInteraction() {
if (this.audioContext) return; // Bereits initialisiert
try {
// Übernehme ggf. global vorhandenen Context
if (window.__TaxiAudioContext) {
this.audioContext = window.__TaxiAudioContext;
} else {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
window.__TaxiAudioContext = this.audioContext;
}
// Autoplay-Policy: Context sofort im User-Gesture fortsetzen
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
if (!this.motorSound) {
// Ggf. globalen Motor übernehmen
if (window.__TaxiMotorSound) {
this.motorSound = window.__TaxiMotorSound;
} else {
const generator = new NoiseGenerator();
this.motorSound = new MotorSound(this.audioContext, generator);
await this.motorSound.init();
window.__TaxiMotorSound = this.motorSound;
}
}
// Falls bereits Bewegung anliegt, Sound direkt starten
const speedKmh = this.taxi.speed * 5;
if (speedKmh > 0) {
this.motorSound.start();
// Sofort Parameter setzen
const speedFactor = Math.min(speedKmh / 120, 1);
const motorSpeed = 0.3 + (speedFactor * 0.3);
const volume = 0.1 + (speedFactor * 0.4);
this.motorSound.setSpeed(motorSpeed);
this.motorSound.setVolume(volume);
}
} catch (error) {
console.warn('[Audio] init: Fehler', error);
}
},
updateMotorSound() {
if (!this.motorSound) return;
const speedKmh = this.taxi.speed * 5; // Geschwindigkeit in km/h
const isMoving = speedKmh > 0;
// Start NICHT hier (kein User-Gesture). Stop mit 1s Verzögerung, wenn still
if (!isMoving) {
if (this.motorStopTimeout == null && this.motorSound.isPlaying) {
this.motorStopTimeout = setTimeout(() => {
// Nur stoppen, wenn weiterhin keine Bewegung
const still = (this.taxi.speed * 5) <= 0;
if (still && this.motorSound && this.motorSound.isPlaying) {
this.motorSound.stop();
}
this.motorStopTimeout = null;
}, 500);
}
} else {
// Bewegung: ggf. geplanten Stopp abbrechen
if (this.motorStopTimeout) {
clearTimeout(this.motorStopTimeout);
this.motorStopTimeout = null;
}
}
if (isMoving) {
// Geschwindigkeitsabhängige Tonhöhe und Lautstärke
const speedFactor = Math.min(speedKmh / 120, 1); // 0-1 basierend auf 0-120 km/h
const motorSpeed = 0.3 + (speedFactor * 0.3); // 0.3 bis 1.5
const volume = 0.1 + (speedFactor * 0.4); // 0.1 bis 0.5
this.motorSound.setSpeed(motorSpeed);
this.motorSound.setVolume(volume);
}
},
cleanup() {
console.log('🧹 Starting cleanup...');
// Game Loop stoppen
if (this.gameLoop) {
clearTimeout(this.gameLoop);
this.gameLoop = null;
}
// Motor Sound stoppen
if (this.motorSound) {
this.motorSound.stop();
this.motorSound = null;
}
// Alle Timeouts bereinigen
if (this.motorStopTimeout) {
clearTimeout(this.motorStopTimeout);
this.motorStopTimeout = null;
}
if (this.passengerGenerationTimeout) {
clearTimeout(this.passengerGenerationTimeout);
this.passengerGenerationTimeout = null;
}
if (this.crashDialogTimeout) {
clearTimeout(this.crashDialogTimeout);
this.crashDialogTimeout = null;
}
if (this.memoryCleanupInterval) {
clearInterval(this.memoryCleanupInterval);
this.memoryCleanupInterval = null;
}
// Event-Listener von Document entfernen
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
// Audio Unlock Handler bereinigen
if (this.audioUnlockHandler) {
document.removeEventListener('pointerdown', this.audioUnlockHandler, { capture: true });
document.removeEventListener('touchstart', this.audioUnlockHandler, { capture: true });
document.removeEventListener('keydown', this.audioUnlockHandler, { capture: true });
this.audioUnlockHandler = null;
}
// Listen und Sets bereinigen
this.waitingPassengersList = [];
this.loadedPassengersList = [];
this.occupiedHouses.clear();
this.cars = []; // Autos bereinigen
// Canvas Context bereinigen
if (this.ctx) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx = null;
}
// Canvas Referenz bereinigen
this.canvas = null;
// Audio Context bereinigen (optional - kann global bleiben)
// if (this.audioContext && this.audioContext.state !== 'closed') {
// this.audioContext.close();
// this.audioContext = null;
// }
// Maps und Objekte bereinigen
this.trafficLightStates = {};
this.keys = {};
// Bilder und Assets bereinigen
this.taxiImage = null;
this.houseImage = null;
this.passengerImages = {};
this.carImage = null; // Auto-Bild bereinigen
this.tiles = null;
console.log('🧹 Cleanup completed');
},
// Regelmäßige Memory-Cleanup-Methode
performMemoryCleanup() {
console.log('🧹 Performing memory cleanup...');
// Canvas NICHT leeren das verursacht sichtbares Flackern / Grau
// Wir verlassen uns auf das reguläre render() zum Überschreiben des Frames
// Traffic Light States aggressiver bereinigen
if (this.trafficLightStates && Object.keys(this.trafficLightStates).length > 20) {
console.log('🧹 Cleaning up traffic light states');
// Nur States für aktuelle Map behalten
if (this.currentMap && this.currentMap.tiles) {
const currentTileKeys = new Set();
this.currentMap.tiles.forEach(tile => {
if (tile.trafficLight || (tile.meta && tile.meta.trafficLight)) {
currentTileKeys.add(`${tile.x},${tile.y}`);
}
});
// Entferne States für nicht mehr existierende Tiles
Object.keys(this.trafficLightStates).forEach(key => {
if (!currentTileKeys.has(key)) {
delete this.trafficLightStates[key];
}
});
} else {
// Fallback: komplett leeren wenn keine Map
this.trafficLightStates = {};
}
}
// Passagier-Listen aggressiver begrenzen
if (this.waitingPassengersList && this.waitingPassengersList.length > 20) {
console.log('🧹 Trimming waiting passengers list');
this.waitingPassengersList = this.waitingPassengersList.slice(-10);
}
if (this.loadedPassengersList && this.loadedPassengersList.length > 20) {
console.log('🧹 Trimming loaded passengers list');
this.loadedPassengersList = this.loadedPassengersList.slice(-10);
}
// Autos-Liste NICHT hart trimmen, um keine abrupten Desyncs zu erzeugen
// Keys-Objekt bereinigen (entferne nicht mehr gedrückte Tasten)
if (this.keys) {
const activeKeys = Object.keys(this.keys).filter(key => this.keys[key]);
if (activeKeys.length === 0) {
this.keys = {};
}
}
// Force Garbage Collection (falls verfügbar)
if (window.gc) {
window.gc();
}
console.log('🧹 Memory cleanup completed');
},
generateLevel() {
this.passengers = [];
this.destinations = [];
this.obstacles = [];
// Passagiere/Ziele werden aktuell nicht generiert
// Generiere Hindernisse (außerhalb der Straßen)
for (let i = 0; i < 3; i++) {
const position = this.getRandomOffRoadPosition();
this.obstacles.push({
x: position.x,
y: position.y,
width: 20,
height: 20
});
}
},
initializePassengerGeneration() {
// Setze zufälliges Intervall für nächste Passagier-Generierung (10-35 Sekunden)
this.passengerGenerationInterval = Math.floor(Math.random() * 25000) + 10000; // 10-35 Sekunden
this.lastPassengerGeneration = Date.now();
this.occupiedHouses.clear();
// Generiere sofort einen Passagier, falls die Map bereits geladen ist
if (this.currentMap && Array.isArray(this.currentMap.tileHouses) && this.currentMap.tileHouses.length > 0) {
this.generateWaitingPassenger();
} else {
// Falls die Map noch nicht geladen ist, versuche es in 2 Sekunden erneut
this.passengerGenerationTimeout = setTimeout(() => {
this.generateWaitingPassenger();
}, 2000);
}
},
generateWaitingPassenger() {
if (!this.currentMap || !Array.isArray(this.currentMap.tileHouses) || !Array.isArray(this.currentMap.tileStreets)) {
// Versuche es in 2 Sekunden erneut
this.passengerGenerationTimeout = setTimeout(() => {
this.generateWaitingPassenger();
}, 2000);
return;
}
// 1. Sammle alle Straßen mit verfügbaren Häusern
const streetsWithHouses = [];
for (const street of this.currentMap.tileStreets) {
// Finde alle Häuser auf diesem Straßen-Tile
const housesOnThisTile = this.currentMap.tileHouses.filter(house =>
house.x === street.x && house.y === street.y
);
if (housesOnThisTile.length > 0) {
// Prüfe ob diese Straße gültige Straßennamen hat
const hasValidStreetName = (street.streetNameH && street.streetNameH.name && street.streetNameH.name.trim() !== '') ||
(street.streetNameV && street.streetNameV.name && street.streetNameV.name.trim() !== '');
if (hasValidStreetName) {
streetsWithHouses.push({
street: street,
houses: housesOnThisTile
});
}
}
}
if (streetsWithHouses.length === 0) {
console.log('Keine Straßen mit verfügbaren Häusern gefunden');
return;
}
// 2. Wähle zufällige Straße mit Häusern
const selectedStreetData = streetsWithHouses[Math.floor(Math.random() * streetsWithHouses.length)];
const selectedStreet = selectedStreetData.street;
const availableHouses = selectedStreetData.houses;
// 3. Wähle zufälliges Haus auf dieser Straße
const selectedHouse = availableHouses[Math.floor(Math.random() * availableHouses.length)];
const houseIndex = this.currentMap.tileHouses.findIndex(h => h === selectedHouse);
const houseId = `${selectedHouse.x}-${selectedHouse.y}-${houseIndex}`;
// Prüfe ob das Haus bereits belegt ist
if (this.occupiedHouses.has(houseId)) {
// Haus bereits belegt, versuche es erneut
this.generateWaitingPassenger();
return;
}
// 4. Bestimme Straßenname basierend auf der Haus-Ecke
let streetName = null;
const corner = selectedHouse.corner;
if (corner === 'lo' || corner === 'ro') {
// Horizontale Straße
if (selectedStreet.streetNameH && selectedStreet.streetNameH.name) {
streetName = selectedStreet.streetNameH.name;
}
} else if (corner === 'lu' || corner === 'ru') {
// Vertikale Straße
if (selectedStreet.streetNameV && selectedStreet.streetNameV.name) {
streetName = selectedStreet.streetNameV.name;
}
}
// Fallback sollte nie auftreten, da wir nur Straßen mit gültigen Namen auswählen
if (!streetName || streetName.trim() === '') {
console.error('Fehler: Kein gültiger Straßenname gefunden für Haus:', selectedHouse);
this.generateWaitingPassenger(); // Versuche es erneut
return;
}
// 5. Finde die Hausnummer für dieses spezifische Haus
const houseKey = `${selectedHouse.x},${selectedHouse.y},${selectedHouse.corner}`;
const houseNumber = this.houseNumbers[houseKey] || 1;
// 6. Generiere Namen und Geschlecht
const nameData = this.generatePassengerName();
// 7. Erstelle Passagier
const passenger = {
id: Date.now() + Math.random(), // Eindeutige ID
name: nameData.fullName,
gender: nameData.gender,
location: `${streetName} ${houseNumber}`,
houseId: houseId,
tileX: selectedHouse.x,
tileY: selectedHouse.y,
houseIndex: houseIndex,
houseCorner: selectedHouse.corner, // Speichere auch die Ecke
imageIndex: this.generatePassengerImageIndex(nameData.gender), // Bildindex basierend auf Geschlecht
createdAt: Date.now() // Zeitstempel der Erstellung
};
// 8. Füge Passagier zur Liste hinzu und markiere Haus als belegt
this.waitingPassengersList.push(passenger);
this.occupiedHouses.add(houseId);
},
generatePassengerName() {
const maleNames = [
'Max', 'Tom', 'Ben', 'Lukas', 'Paul', 'Felix', 'Jonas', 'Tim', 'Noah', 'Finn',
'Leon', 'Liam', 'Elias', 'Henry', 'Anton', 'Theo', 'Emil', 'Oskar', 'Mats', 'Luis',
'Alexander', 'Sebastian', 'David', 'Daniel', 'Michael', 'Christian', 'Andreas', 'Stefan',
'Markus', 'Thomas', 'Matthias', 'Martin', 'Peter', 'Klaus', 'Wolfgang', 'Jürgen',
'Rainer', 'Uwe', 'Dieter', 'Hans', 'Günter', 'Karl', 'Walter', 'Helmut', 'Manfred',
'Jens', 'Sven', 'Tobias', 'Nils', 'Jan', 'Marcel', 'Kevin', 'Dennis', 'Marco',
'Philipp', 'Simon', 'Florian', 'Dominik', 'Patrick', 'Oliver', 'Timo', 'Björn'
];
const femaleNames = [
'Anna', 'Lisa', 'Sarah', 'Emma', 'Julia', 'Marie', 'Sophie', 'Laura', 'Mia', 'Emma',
'Lina', 'Mila', 'Hannah', 'Lea', 'Emilia', 'Clara', 'Leni', 'Marlene', 'Frieda', 'Ida',
'Katharina', 'Christina', 'Nicole', 'Stefanie', 'Jennifer', 'Jessica', 'Melanie', 'Sandra',
'Andrea', 'Petra', 'Monika', 'Ursula', 'Brigitte', 'Ingrid', 'Gisela', 'Elisabeth',
'Maria', 'Barbara', 'Helga', 'Gertrud', 'Irmgard', 'Hildegard', 'Waltraud', 'Renate',
'Sabine', 'Kerstin', 'Anja', 'Tanja', 'Nadine', 'Katrin', 'Silke', 'Birgit',
'Susanne', 'Martina', 'Karin', 'Heike', 'Doris', 'Elke', 'Bärbel', 'Jutta',
'Gabriele', 'Angelika', 'Christine', 'Annette', 'Beate', 'Cornelia', 'Diana', 'Eva'
];
const lastNames = [
'Müller', 'Schmidt', 'Schneider', 'Fischer', 'Weber', 'Meyer', 'Wagner', 'Becker',
'Schulz', 'Hoffmann', 'Schäfer', 'Koch', 'Bauer', 'Richter', 'Klein', 'Wolf',
'Schröder', 'Neumann', 'Schwarz', 'Zimmermann', 'Braun', 'Hofmann', 'Lange', 'Schmitt',
'Werner', 'Schmitz', 'Krause', 'Meier', 'Lehmann', 'Köhler', 'Huber', 'Kaiser',
'Fuchs', 'Peters', 'Lang', 'Scholz', 'Möller', 'Weiß', 'Jung', 'Hahn', 'Schubert',
'Schuster', 'Winkler', 'Berger', 'Roth', 'Beck', 'Lorenz', 'Baumann', 'Franke',
'Albrecht', 'Ludwig', 'Winter', 'Simon', 'Kraus', 'Vogt', 'Stein', 'Jäger',
'Otto', 'Sommer', 'Groß', 'Seidel', 'Heinrich', 'Brandt', 'Haas', 'Schreiber',
'Graf', 'Schulte', 'Dietrich', 'Ziegler', 'Kuhn', 'Pohl', 'Engel', 'Horn'
];
// Wähle zufällig zwischen männlich und weiblich
const isMale = Math.random() < 0.5;
const firstNames = isMale ? maleNames : femaleNames;
const firstName = firstNames[Math.floor(Math.random() * firstNames.length)];
const lastName = lastNames[Math.floor(Math.random() * lastNames.length)];
return {
fullName: `${firstName} ${lastName}`,
gender: isMale ? 'male' : 'female'
};
},
generatePassengerImageIndex(gender) {
// Bildindex basierend auf Geschlecht (1-3 männlich, 4-6 weiblich)
if (gender === 'male') {
return Math.floor(Math.random() * 3) + 1; // 1-3
} else {
return Math.floor(Math.random() * 3) + 4; // 4-6
}
},
getTilesWithHouses() {
// Erstelle Liste aller Tiles, die Häuser haben
const tilesWithHouses = [];
const houses = this.currentMap.tileHouses || [];
// Sammle alle eindeutigen Tile-Koordinaten
const tileSet = new Set();
houses.forEach(house => {
const tileKey = `${house.x}-${house.y}`;
if (!tileSet.has(tileKey)) {
tileSet.add(tileKey);
tilesWithHouses.push({ x: house.x, y: house.y });
}
});
return tilesWithHouses;
},
calculateShortestPath(startX, startY, endX, endY) {
// Berechne Manhattan-Distanz (kürzester Weg in einem Raster)
const distance = Math.abs(endX - startX) + Math.abs(endY - startY);
// Wenn Start und Ziel identisch sind, mindestens 1 Tile Distanz
// (da das Taxi das Tile verlassen und wieder betreten muss)
if (distance === 0) {
return 1;
}
// Für unterschiedliche Tiles: Distanz + 1 (Start-Tile wird mitgezählt)
return distance + 1;
},
calculateBonusAndTime(startX, startY, endX, endY) {
const shortestPath = this.calculateShortestPath(startX, startY, endX, endY);
const bonus = shortestPath * this.bonusMultiplier;
const maxTime = shortestPath * this.timePerTile;
console.log(`Wegstrecke berechnet: Start(${startX},${startY}) -> Ziel(${endX},${endY}) = ${shortestPath} Tiles, Bonus: ${bonus}, Zeit: ${maxTime}s`);
return {
shortestPath,
bonus,
maxTime
};
},
generatePassengerDestination() {
// Generiere ein zufälliges Ziel für den Passagier
if (!this.currentMap || !Array.isArray(this.currentMap.tileStreets) || !Array.isArray(this.currentMap.tileHouses)) {
return null;
}
// Finde alle Straßen-Tiles auf der Karte
const streets = this.currentMap.tileStreets || [];
const houses = this.currentMap.tileHouses || [];
// Sammle alle gültigen Ziele vor der zufälligen Auswahl
const validDestinations = [];
for (const street of streets) {
// Prüfe ob diese Straße Häuser hat
const housesOnThisTile = houses.filter(house => house.x === street.x && house.y === street.y);
if (housesOnThisTile.length > 0) {
for (const house of housesOnThisTile) {
const corner = house.corner;
let streetName = null;
if (corner === 'lo' || corner === 'ro') {
// Horizontale Straße
if (street.streetNameH && street.streetNameH.name) {
streetName = street.streetNameH.name;
}
} else if (corner === 'lu' || corner === 'ru') {
// Vertikale Straße
if (street.streetNameV && street.streetNameV.name) {
streetName = street.streetNameV.name;
}
}
// Nur gültige Straßen hinzufügen
if (streetName && streetName.trim() !== '') {
// Hole Hausnummern nur für dieses spezifische Tile
const key = `${street.x},${street.y},${house.corner}`;
const houseNumber = this.houseNumbers[key];
if (houseNumber != null) {
validDestinations.push({
streetName,
houseNumber,
location: `${streetName} ${houseNumber}`,
tileX: street.x,
tileY: street.y,
houseCorner: house.corner
});
}
}
}
}
}
if (validDestinations.length > 0) {
// Wähle zufälliges gültiges Ziel
const selectedDestination = validDestinations[Math.floor(Math.random() * validDestinations.length)];
return selectedDestination;
}
return null;
},
updatePassengerGeneration() {
const now = Date.now();
const timeSinceLastGeneration = now - this.lastPassengerGeneration;
if (timeSinceLastGeneration >= this.passengerGenerationInterval) {
this.generateWaitingPassenger();
this.lastPassengerGeneration = now;
// Setze nächstes Intervall (10-35 Sekunden)
this.passengerGenerationInterval = Math.floor(Math.random() * 25000) + 10000;
}
},
updatePassengerTimers() {
// Aktualisiere die verbleibende Zeit für alle geladenen Passagiere
const now = Date.now();
this.loadedPassengersList.forEach(passenger => {
if (passenger.pickedUpAt && passenger.bonusData) {
// Aktiviere Timer nach 2 Sekunden
if (!passenger.timerActive) {
const timeSincePickup = now - passenger.pickedUpAt;
if (timeSincePickup >= 2000) {
passenger.timerActive = true;
passenger.lastUpdateTime = now;
}
return; // Timer noch nicht aktiv
}
// Throttle: Nur alle 100ms aktualisieren
if (passenger.lastUpdateTime && (now - passenger.lastUpdateTime) < 100) {
return;
}
const timeSincePickup = now - passenger.pickedUpAt;
const elapsedTime = Math.floor(timeSincePickup / 1000);
// Berechne die verbleibende Zeit, aber lasse sie nicht unter 0 fallen
const remainingTime = passenger.bonusData.maxTime - elapsedTime;
passenger.timeLeft = Math.max(0, remainingTime);
passenger.lastUpdateTime = now;
}
});
},
// Prüft, ob das Taxi im Tank-Bereich ist und stillsteht
checkRefuelingConditions() {
const tileType = this.getCurrentTileType();
// Nur auf Fuel-Tiles prüfen
if (tileType !== 'fuelhorizontal' && tileType !== 'fuelvertical') {
return false;
}
// Taxi muss stillstehen (Geschwindigkeit = 0)
if (this.taxi.speed !== 0) {
return false;
}
// Prüfe Position im Tank-Bereich (absolute Koordinaten)
if (tileType === 'fuelhorizontal') {
// Y-Bereich zwischen 0.195 und 0.299 (relativ) = 97.5px und 149.5px (absolut)
const minY = 0.195 * 500; // 97.5px
const maxY = 0.299 * 500; // 149.5px
return this.taxi.y >= minY && this.taxi.y <= maxY;
} else if (tileType === 'fuelvertical') {
// X-Bereich zwischen 0.701 und 0.805 (relativ) = 350.5px und 402.5px (absolut)
const minX = 0.701 * 500; // 350.5px
const maxX = 0.805 * 500; // 402.5px
return this.taxi.x >= minX && this.taxi.x <= maxX;
}
return false;
},
// Startet das Tanken
startRefueling() {
if (this.isRefueling || this.fuel >= 100) {
return; // Bereits am Tanken oder Tank voll
}
this.isRefueling = true;
// Tanken alle 0.1 Sekunden (100ms)
this.refuelTimer = setInterval(() => {
if (!this.checkRefuelingConditions()) {
this.stopRefueling();
return;
}
// Tanke 0.1% pro 0.1 Sekunden
this.fuel = Math.min(100, this.fuel + 0.1);
// Stoppe wenn Tank voll
if (this.fuel >= 100) {
this.stopRefueling();
}
}, 100);
},
// Stoppt das Tanken
stopRefueling() {
if (this.refuelTimer) {
clearInterval(this.refuelTimer);
this.refuelTimer = null;
}
this.isRefueling = false;
},
// Behandelt leeren Tank - Crash und Tank auffüllen
handleEmptyTank() {
// Stoppe das Tanken falls aktiv
this.stopRefueling();
// Crash durch leeren Tank
this.countCrash('emptytank');
// Zeige Crash-Dialog
this.showCrashDialog('Leerer Tank! Der Tank wurde automatisch aufgefüllt.');
},
removePassengerFromWaitingList() {
if (this.waitingPassengersList.length > 0) {
// Entferne den ersten Passagier aus der Liste
const removedPassenger = this.waitingPassengersList.shift();
if (removedPassenger && removedPassenger.houseId) {
// Gib das Haus wieder frei
this.occupiedHouses.delete(removedPassenger.houseId);
}
}
},
removeExpiredPassengers() {
const now = Date.now();
const maxAge = 30000; // 30 Sekunden in Millisekunden
// Filtere abgelaufene Passagiere heraus
const expiredPassengers = [];
this.waitingPassengersList = this.waitingPassengersList.filter(passenger => {
const age = now - passenger.createdAt;
if (age > maxAge) {
expiredPassengers.push(passenger);
// Gib das Haus wieder frei
if (passenger.houseId) {
this.occupiedHouses.delete(passenger.houseId);
}
return false; // Entferne aus der Liste
}
return true; // Behalte in der Liste
});
// Passagiere wurden entfernt (abgelaufen)
},
// Autos-Generierung mit 20% Wahrscheinlichkeit pro Sekunde
updateCarGeneration() {
const now = Date.now();
// Prüfe alle Sekunde
if (now - this.lastCarGeneration < this.carGenerationInterval) {
return;
}
this.lastCarGeneration = now;
// 20% Wahrscheinlichkeit pro Sekunde
if (!this.isPaused && Math.random() < this.carSpawnProbability) {
console.log('[🚗 GENERATION] Attempting to spawn car, current cars:', this.cars.length);
this.spawnCar(now);
}
},
// Spawne ein neues Auto
spawnCar(now = Date.now()) {
// Begrenze die Anzahl der Autos (TEST: nur 1 Auto)
if (this.cars.length >= 1) {
console.log('[🚗 SPAWN] Max cars reached:', this.cars.length);
return;
}
// Spawne Auto auf einer befahrbaren Position
const spawnPosition = this.getRandomCarSpawnPosition();
if (!spawnPosition) {
console.log('[🚗 SPAWN] No valid spawn position found');
return; // Keine gültige Spawn-Position gefunden
}
const car = {
id: Date.now() + Math.random(), // Eindeutige ID
x: spawnPosition.x,
y: spawnPosition.y,
angle: spawnPosition.angle,
speed: 0.3 + Math.random() * 0.7, // Langsamere Geschwindigkeit zwischen 0.3-1.0
width: 60, // Etwas breiter als Taxi
height: 50, // Gleiche Höhe wie Taxi
createdAt: now,
color: this.getRandomCarColor(),
isCar: true,
currentTile: { row: 0, col: 0 }, // Aktuelle Tile-Position
direction: spawnPosition.direction, // Fahrtrichtung
lastPosition: { x: spawnPosition.x, y: spawnPosition.y }, // Letzte Position für Bewegung
hasTurnedAtIntersection: false, // Flag um mehrfaches Abbiegen zu verhindern
targetDirection: null, // Zielrichtung an der Kreuzung (wird beim Tile-Betreten festgelegt)
targetLane: null // Zielspur basierend auf Zielrichtung
};
console.log(`[🚗 CREATE] New car created - ID: ${car.id}, Position: x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}, direction=${car.direction}, angle=${car.angle.toFixed(2)}, speed=${car.speed.toFixed(2)}`);
this.cars.push(car);
console.log(`[🚗 CREATE] Added car with ID: ${car.id}, Total cars now: ${this.cars.length}`);
console.log(`[🚗 CREATE] Car details: x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}, direction=${car.direction}, speed=${car.speed.toFixed(2)}`);
},
// Ermittelt erlaubte Spawn-Richtungen basierend auf dem aktuellen Tile-Typ
// WICHTIG: Diese Methode gibt die BEWEGUNGSRICHTUNGEN zurück, nicht die Spawn-Seiten!
getAllowedSpawnDirections() {
const tileType = this.getCurrentTileType();
// Basierend auf der tile-Struktur und welche Seiten offen sind
switch (tileType) {
case 'cornertopleft':
return ['left', 'up']; // Bewegt sich nach links (von rechts spawnen) und nach oben (von unten spawnen)
case 'cornertopright':
return ['right', 'up']; // Bewegt sich nach rechts (von links spawnen) und nach oben (von unten spawnen)
case 'cornerbottomleft':
return ['left', 'down']; // Bewegt sich nach links (von rechts spawnen) und nach unten (von oben spawnen)
case 'cornerbottomright':
return ['left', 'up']; // Bewegt sich nach links (von rechts spawnen) und nach oben (von unten spawnen)
case 'horizontal':
case 'fuelhorizontal':
return ['left', 'right']; // Bewegt sich nach links (von rechts spawnen) und nach rechts (von links spawnen)
case 'vertical':
case 'fuelvertical':
return ['up', 'down']; // Bewegt sich nach oben (von unten spawnen) und nach unten (von oben spawnen)
case 'cross':
return ['left', 'right', 'up', 'down']; // Kann von allen Seiten spawnen
case 'tup':
return ['up', 'left', 'right']; // Bewegt sich nach oben, links und rechts
case 'tdown':
return ['down', 'left', 'right']; // Bewegt sich nach unten, links und rechts
case 'tleft':
return ['left', 'up', 'down']; // Bewegt sich nach links, oben und unten
case 'tright':
return ['right', 'up', 'down']; // Bewegt sich nach rechts, oben und unten
default:
return []; // Keine erlaubten Spawn-Richtungen
}
},
// Finde eine zufällige befahrbare Spawn-Position für ein Auto
getRandomCarSpawnPosition() {
if (!this.currentMap || !this.currentMap.tiles) {
console.log('[🚗 SPAWN] No current map or tiles');
return null;
}
const tileSize = this.tiles.size;
const tileType = this.getCurrentTileType();
// Erlaubte Spawn-Richtungen für den aktuellen Tile-Typ ermitteln
const allowedDirections = this.getAllowedSpawnDirections();
console.log('[🚗 SPAWN] Tile type:', tileType, 'Allowed directions:', allowedDirections);
console.log('[🚗 SPAWN] Tile size:', tileSize);
if (allowedDirections.length === 0) {
console.log('[🚗 SPAWN] No allowed spawn directions');
return null; // Keine erlaubten Spawn-Richtungen
}
// Definiere die Spawn-Positionen (relativ 0-1) basierend auf der Richtung
// Autos müssen von außerhalb des Tiles spawnen, um dann hineinzufahren
// Korrekte Fahrspuren: links (y=0.55-0.625), rechts (y=0.375-0.45), oben (x=0.375-0.45), unten (x=0.55-0.625)
// WICHTIG: direction ist die Bewegungsrichtung, nicht die Spawn-Seite!
// Auto-Dimensionen
const carWidth = 60;
const carHeight = 50;
// Spawn-Positionen basierend auf den tatsächlichen Straßenlinien in streetCoordinates.json
// cornerBottomRight hat vertikale Linien bei x=0.375 (187.5px) und x=0.625 (312.5px)
// cornerBottomRight hat horizontale Linien bei y=0.375 (187.5px) und y=0.625 (312.5px)
// Spur-Bänder: innerer Rand (≈ 15px bei 500px → 0.03 rel)
const laneMargin = 0.03;
const spawnPositionsMap = {
'right': { relativeX: -0.02, pickRelY: () => 0.5 + laneMargin + Math.random() * (0.625 - 0.5 - 2 * laneMargin), angle: 0, direction: 'right', laneAxis: 'H' },
'left': { relativeX: 1.02, pickRelY: () => 0.375 + laneMargin + Math.random() * (0.5 - 0.375 - 2 * laneMargin), angle: Math.PI, direction: 'left', laneAxis: 'H' },
'down': { pickRelX: () => 0.375 + laneMargin + Math.random() * (0.5 - 0.375 - 2 * laneMargin), relativeY: -0.02, angle: Math.PI / 2, direction: 'down', laneAxis: 'V' },
'up': { pickRelX: () => 0.5 + laneMargin + Math.random() * (0.625 - 0.5 - 2 * laneMargin), relativeY: 1.02, angle: -Math.PI / 2, direction: 'up', laneAxis: 'V' }
};
// Wähle eine zufällige erlaubte Richtung
const randomDirection = allowedDirections[Math.floor(Math.random() * allowedDirections.length)];
const spawnPos = spawnPositionsMap[randomDirection];
if (!spawnPos) {
return null;
}
console.log('[🚗 SPAWN] Selected direction:', randomDirection);
console.log('[🚗 SPAWN] Spawn position data:', spawnPos);
// Konvertiere relative Koordinaten zu absoluten Pixeln
let x = (spawnPos.relativeX != null ? spawnPos.relativeX : (spawnPos.pickRelX ? spawnPos.pickRelX() : 0.5)) * tileSize;
let y = (spawnPos.relativeY != null ? spawnPos.relativeY : (spawnPos.pickRelY ? spawnPos.pickRelY() : 0.5)) * tileSize;
console.log('[🚗 SPAWN] Before adjustment - x:', x.toFixed(2), 'y:', y.toFixed(2));
// Passe Koordinaten an: Die Spawn-Position ist die Straßenlinie, aber wir brauchen die linke obere Ecke des Autos
// Das Auto muss auf der Linie zentriert sein
switch (randomDirection) {
case 'left':
case 'right':
// Horizontale Bewegung: Auto muss auf Y-Linie zentriert sein
y = y - carHeight / 2; // Zentriere Auto auf horizontaler Linie
if (x < 0) x = -carWidth; // Links außerhalb
if (x > tileSize) x = tileSize; // Rechts außerhalb
break;
case 'up':
case 'down':
// Vertikale Bewegung: Auto muss auf X-Linie zentriert sein
x = x - carWidth / 2; // Zentriere Auto auf vertikaler Linie
if (y < 0) y = -carHeight; // Oben außerhalb
if (y > tileSize) y = tileSize; // Unten außerhalb
break;
}
console.log('[🚗 SPAWN] After adjustment - x:', x.toFixed(2), 'y:', y.toFixed(2));
console.log('[🚗 SPAWN] Direction:', randomDirection, 'Absolute:', { x: x.toFixed(2), y: y.toFixed(2) });
// laneCoord speichert die Zielspur (Mitte) auf der Querachse
// Verwende die bereits berechneten absoluten x/y, um exakt dieselbe Spur zu halten
const laneCoord = spawnPos.laneAxis === 'H'
? (y + carHeight / 2) // Y-Mitte
: (x + carWidth / 2); // X-Mitte
return {
x: x,
y: y,
angle: spawnPos.angle,
direction: spawnPos.direction,
laneAxis: spawnPos.laneAxis,
laneCoord: laneCoord
};
},
// Prüfe ob eine Position befahrbar ist
isPositionDriveable(x, y) {
const tileType = this.getCurrentTileType();
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
// Konvertiere absolute Koordinaten zu relativen (0-1)
const relativeX = x / this.tiles.size;
const relativeY = y / this.tiles.size;
return streetCoordinates.isPointDriveable(relativeX, relativeY, streetTileType, 1);
},
// Prüfe ob Auto auf einer befahrbaren Straße ist (wie Taxi-Logik)
isCarOnRoad(car) {
const centerX = car.x + car.width / 2;
const centerY = car.y + car.height / 2;
// Außerhalb des Tiles? Dann ist es okay (Autos dürfen rein/raus fahren)
if (centerX < 0 || centerY < 0 || centerX >= this.tiles.size || centerY >= this.tiles.size) {
return true;
}
// Innerhalb des Tiles: Prüfe mehrere Punkte um das Auto herum (nicht nur Zentrum)
// Das Auto ist 60x50, also prüfe auch Ecken
const points = [
{ x: centerX, y: centerY, name: 'center' }, // Zentrum
{ x: car.x + 10, y: car.y + 10, name: 'top-left' }, // Linke obere Ecke (mit Padding)
{ x: car.x + car.width - 10, y: car.y + 10, name: 'top-right' }, // Rechte obere Ecke
{ x: car.x + 10, y: car.y + car.height - 10, name: 'bottom-left' }, // Linke untere Ecke
{ x: car.x + car.width - 10, y: car.y + car.height - 10, name: 'bottom-right' } // Rechte untere Ecke
];
// Auto ist auf Straße, wenn mindestens einer der Punkte auf der Straße ist
let onRoadPoints = [];
for (const point of points) {
if (this.isPositionDriveable(point.x, point.y)) {
onRoadPoints.push(point.name);
}
}
if (onRoadPoints.length > 0) {
console.log(`[🚗 ON_ROAD] Car at x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)} - ON ROAD (points: ${onRoadPoints.join(', ')})`);
return true;
} else {
console.log(`[🚗 OFF_ROAD] Car at x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}, centerX=${centerX.toFixed(2)}, centerY=${centerY.toFixed(2)} - OFF ROAD (no points on road)`);
return false;
}
},
// Prüfe ob Auto nahe an einer Kreuzung/Abzweigung ist und Richtung ändern sollte
shouldCarChangeDirection(car) {
const centerX = car.x + car.width / 2;
const centerY = car.y + car.height / 2;
// Zufälliger Kollisionsbereich: 5-20 Pixel vor der tatsächlichen Grenze
const randomOffset = 5 + Math.random() * 15;
// Hole erlaubte Richtungen für aktuelles Tile
const allowedDirections = this.getAllowedSpawnDirections();
// Prüfe je nach aktueller Richtung, ob eine Änderung möglich ist
switch (car.direction) {
case 'up':
// Wenn nahe am oberen Rand und andere Richtungen möglich sind
if (centerY <= randomOffset && allowedDirections.length > 1) {
return allowedDirections.filter(d => d !== 'down' && d !== 'up'); // Nicht zurück, nicht weitergerade wenn am Rand
}
break;
case 'down':
// Wenn nahe am unteren Rand
if (centerY >= this.tiles.size - randomOffset && allowedDirections.length > 1) {
return allowedDirections.filter(d => d !== 'up' && d !== 'down');
}
break;
case 'left':
// Wenn nahe am linken Rand
if (centerX <= randomOffset && allowedDirections.length > 1) {
return allowedDirections.filter(d => d !== 'right' && d !== 'left');
}
break;
case 'right':
// Wenn nahe am rechten Rand
if (centerX >= this.tiles.size - randomOffset && allowedDirections.length > 1) {
return allowedDirections.filter(d => d !== 'left' && d !== 'right');
}
break;
}
return null; // Keine Richtungsänderung nötig
},
// Zufällige Auto-Farbe
getRandomCarColor() {
const colors = [
'#FF0000', // Rot
'#0000FF', // Blau
'#00FF00', // Grün
'#FFFF00', // Gelb
'#FF00FF', // Magenta
'#00FFFF', // Cyan
'#FFFFFF', // Weiß
'#000000' // Schwarz
];
return colors[Math.floor(Math.random() * colors.length)];
},
// Aktualisiere alle Autos
updateCars() {
const now = Date.now();
const maxAge = 45000; // 45 Sekunden
const initialCarCount = this.cars.length;
console.log(`[🚗 UPDATE] Starting with ${initialCarCount} cars`);
this.cars = this.cars.filter((car, index) => {
console.log(`[🚗 UPDATE] Processing car ${index}: x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}, age=${(now - car.createdAt)}ms`);
// Entferne alte Autos
// Bewege das Auto
this.updateCarMovement(car);
// Entferne Autos mit speed=0 (off-road)
if (car.speed === 0) {
console.log(`[🚗 UPDATE] Removing car ${index} - speed=0 (off-road)`);
return false;
}
// Entferne Autos, die komplett außerhalb des sichtbaren Bereichs sind
// Berücksichtige die Auto-Größe: Auto wird erst entfernt, wenn es komplett außerhalb ist
const tileSize = this.tiles.size; // 500px
const carWidth = car.width || 60;
const carHeight = car.height || 50;
// Auto ist komplett außerhalb wenn:
// - Rechte Kante links vom Canvas: x + carWidth < 0
// - Linke Kante rechts vom Canvas: x > tileSize
// - Untere Kante über dem Canvas: y + carHeight < 0
// - Obere Kante unter dem Canvas: y > tileSize
if (car.x + carWidth < 0 || car.x > tileSize ||
car.y + carHeight < 0 || car.y > tileSize) {
console.log(`[🚗 UPDATE] Removing car ${index} - completely out of bounds (x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)})`);
return false;
}
console.log(`[🚗 UPDATE] Keeping car ${index}`);
return true;
});
console.log(`[🚗 UPDATE] Finished with ${this.cars.length} cars (removed ${initialCarCount - this.cars.length})`);
},
// Aktualisiere die Bewegung eines einzelnen Autos (vereinfachte Taxi-Logik)
updateCarMovement(car) {
console.log(`[🚗 MOVE] Car ID: ${car.id} - Current: x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}, direction=${car.direction}`);
// 1) An Abbiegepunkt? Dann (ggf.) vorgegebene Zielrichtung übernehmen
const forcedDir = this.shouldCarTurnAtIntersection(car);
if (forcedDir) {
const tileType = this.getCurrentTileType();
// forcedDir hat Vorrang NICHT durch allowedDirections einschränken
let preferred = forcedDir;
if (preferred && preferred !== car.direction) {
console.log(`[🚗 MOVE] Car ID: ${car.id} - Turning on ${tileType} from ${car.direction} to ${preferred}`);
// Beim Abbiegen: neue Spur bestimmen
car.direction = preferred;
if (preferred === 'left' || preferred === 'right') {
car.laneAxis = 'H';
const laneMargin = 0.03;
const rel = preferred === 'left'
? (0.375 + laneMargin + Math.random() * (0.5 - 0.375 - 2 * laneMargin))
: (0.5 + laneMargin + Math.random() * (0.625 - 0.5 - 2 * laneMargin));
car.laneCoord = rel * this.tiles.size; // Y-Spur
} else {
car.laneAxis = 'V';
const laneMargin = 0.03;
const rel = preferred === 'up'
? (0.5 + laneMargin + Math.random() * (0.625 - 0.5 - 2 * laneMargin))
: (0.375 + laneMargin + Math.random() * (0.5 - 0.375 - 2 * laneMargin));
car.laneCoord = rel * this.tiles.size; // X-Spur
}
switch (preferred) {
case 'up': car.angle = -Math.PI / 2; break;
case 'down': car.angle = Math.PI / 2; break;
case 'left': car.angle = Math.PI; break;
case 'right':car.angle = 0; break;
}
}
}
// 2) Bewege Auto in die aktuelle Richtung und halte es auf seiner Spur
let newX = car.x;
let newY = car.y;
switch (car.direction) {
case 'up': newY = car.y - car.speed; break;
case 'down': newY = car.y + car.speed; break;
case 'left': newX = car.x - car.speed; break;
case 'right': newX = car.x + car.speed; break;
}
// Querachse fixieren
if (car.laneAxis === 'H') {
const centerY = car.laneCoord;
newY = centerY - car.height / 2;
} else if (car.laneAxis === 'V') {
const centerX = car.laneCoord;
newX = centerX - car.width / 2;
}
// 3) Position übernehmen
car.x = newX;
car.y = newY;
console.log(`[🚗 MOVE] Car ID: ${car.id} - Moved to: x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}`);
// 4) Straße prüfen bleibt es auf befahrbarer Fläche?
if (!this.isCarOnRoad(car)) {
console.log(`[🚗 MOVE] Car ID: ${car.id} - OFF ROAD! Stopping car.`);
car.speed = 0;
}
},
getPassengerTimeLeft(passenger) {
const now = Date.now();
const age = now - passenger.createdAt;
const maxAge = 30000; // 30 Sekunden
const timeLeft = Math.max(0, Math.ceil((maxAge - age) / 1000));
return timeLeft;
},
getHouseNumbersForStreet(streetName) {
const houseNumbers = [];
if (!this.currentMap || !this.currentMap.tileStreets || !this.houseNumbers) {
return houseNumbers;
}
// Finde alle Tiles mit dieser Straße
const tilesWithStreet = this.currentMap.tileStreets.filter(ts =>
(ts.streetNameH && ts.streetNameH.name === streetName) ||
(ts.streetNameV && ts.streetNameV.name === streetName)
);
// Sammle nur Hausnummern, die tatsächlich zu dieser Straße gehören
tilesWithStreet.forEach(ts => {
const isHorizontal = ts.streetNameH && ts.streetNameH.name === streetName;
const isVertical = ts.streetNameV && ts.streetNameV.name === streetName;
// Prüfe nur die Ecken, die zu dieser Straße gehören
const relevantCorners = [];
if (isHorizontal) {
// Für horizontale Straßen: linke und rechte Ecken
relevantCorners.push('lo', 'ro'); // left-outer, right-outer
}
if (isVertical) {
// Für vertikale Straßen: obere und untere Ecken
relevantCorners.push('lu', 'ru'); // left-upper, right-upper
}
// Sammle Hausnummern nur von den relevanten Ecken
relevantCorners.forEach(corner => {
const key = `${ts.x},${ts.y},${corner}`;
const houseNumber = this.houseNumbers[key];
if (houseNumber != null && !houseNumbers.includes(houseNumber)) {
houseNumbers.push(houseNumber);
}
});
});
return houseNumbers.sort((a, b) => a - b); // Sortiere aufsteigend
},
getRandomRoadPosition() {
const tileSize = this.tiles.size;
const cols = 8;
const rows = 8;
let attempts = 0;
while (attempts < 100) {
const tileCol = Math.floor(Math.random() * cols);
const tileRow = Math.floor(Math.random() * rows);
const tileType = this.getTileType(tileRow, tileCol, rows, cols);
// Zufällige Position innerhalb des Tiles
const relativeX = Math.random();
const relativeY = Math.random();
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
if (streetCoordinates.isPointDriveable(relativeX, relativeY, streetTileType, 1)) {
return {
x: tileCol * tileSize + relativeX * tileSize,
y: tileRow * tileSize + relativeY * tileSize
};
}
attempts++;
}
// Fallback: Mitte des Spielfelds
return { x: 250, y: 250 };
},
getRandomOffRoadPosition() {
const tileSize = this.tiles.size;
const cols = 8;
const rows = 8;
let attempts = 0;
while (attempts < 100) {
const tileCol = Math.floor(Math.random() * cols);
const tileRow = Math.floor(Math.random() * rows);
const tileType = this.getTileType(tileRow, tileCol, rows, cols);
// Zufällige Position innerhalb des Tiles
const relativeX = Math.random();
const relativeY = Math.random();
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
if (!streetCoordinates.isPointDriveable(relativeX, relativeY, streetTileType, 1)) {
return {
x: tileCol * tileSize + relativeX * tileSize,
y: tileRow * tileSize + relativeY * tileSize
};
}
attempts++;
}
// Fallback: Ecke des Spielfelds
return { x: 20, y: 20 };
},
startGame() {
this.gameRunning = true;
// Lösche alle alten Autos (falls welche existieren)
this.cars = [];
console.log('[🚗 GAME] Cleared all cars on game start');
// Setze Spielstart-Zeit
if (!this.gameStartTime) {
this.gameStartTime = Date.now();
}
// Stoppe bestehende Game-Loop falls vorhanden
if (this.gameLoop) {
clearTimeout(this.gameLoop);
}
this.gameLoop = setTimeout(this.update, 62);
},
update() {
if (!this.gameRunning || this.isPaused) {
// Game Loop komplett stoppen wenn pausiert - kein unnötiges requestAnimationFrame
return;
}
// Debug: Log every 60 frames (about once per second)
if (!this._frameCount) this._frameCount = 0;
this._frameCount++;
if (this._frameCount % 60 === 0) {
console.log(`[🔄 UPDATE] Frame ${this._frameCount}, gameRunning: ${this.gameRunning}, isPaused: ${this.isPaused}, cars: ${this.cars.length}`);
}
// Ampelschaltung tick
this.updateTrafficLights();
// Passagier-Generierung prüfen
this.updatePassengerGeneration();
// Autos-Generierung prüfen
this.updateCarGeneration();
// Autos aktualisieren
this.updateCars();
// Abgelaufene Passagiere entfernen
this.removeExpiredPassengers();
this.updateTaxi();
this.handlePassengerActions();
// Timer für geladene Passagiere aktualisieren (nach Passagier-Aktionen) - gedrosselt
const nowTs = Date.now();
if (nowTs - this.lastPassengerTimerUpdate >= 200) { // Alle 200ms statt jeden Frame
this.updatePassengerTimers();
this.lastPassengerTimerUpdate = nowTs;
}
this.checkCollisions();
this.render();
// Radar-Messung prüfen (falls aktiv)
this.checkRadarMeasurement();
// Minimap zeichnen (gedrosselt)
if (nowTs - this.lastMinimapDraw >= this.minimapDrawInterval) {
this.drawMinimap();
this.lastMinimapDraw = nowTs;
}
// Update-Rate schneller: ~16x pro Sekunde (62ms)
this.gameLoop = setTimeout(this.update, 62);
},
updateTaxi() {
// Prüfe ob das Spiel pausiert ist
if (this.isPaused) {
return;
}
const currentTime = Date.now();
// Prüfe ob genug Zeit vergangen ist für Geschwindigkeitsänderung
if (currentTime - this.lastSpeedChange >= this.speedChangeInterval) {
// Beschleunigung bei gedrücktem Pfeil nach oben (5er-Schritte)
if (this.keys['ArrowUp'] || this.keys['w'] || this.keys['W']) {
this.taxi.speed = Math.min(this.taxi.speed + 1, this.taxi.maxSpeed);
this.lastSpeedChange = currentTime;
}
// Abbremsen bei gedrücktem Pfeil nach unten (5er-Schritte)
if (this.keys['ArrowDown'] || this.keys['x'] || this.keys['X']) {
this.taxi.speed = Math.max(this.taxi.speed - 1, 0); // Bremsen bis 0, nicht rückwärts
this.lastSpeedChange = currentTime;
}
}
// Lenkung in diskreten 11,25°-Schritten mit geschwindigkeitsabhängiger Verzögerung
const steeringAngle = Math.PI / 16; // 11,25°
const speedLevel = this.taxi.speed; // 0..24 (5 km/h je Stufe)
// Nicht-lineare Beschleunigung der Eingabe: schneller bei hoher Geschwindigkeit
const minInterval = 4; // schnellste Annahme (ms) - erneut halbiert
const maxInterval = 60; // langsamste Annahme (ms) - erneut halbiert
const factor = Math.pow(speedLevel / this.taxi.maxSpeed, 1.5); // 0..1, gewichtet
const steeringInterval = Math.max(minInterval, maxInterval - (maxInterval - minInterval) * factor);
// Richtung bestimmen (-1 links, +1 rechts)
let steerDir = 0;
if (this.keys['ArrowLeft'] || this.keys['a'] || this.keys['A']) steerDir = -1;
if (this.keys['ArrowRight'] || this.keys['d'] || this.keys['D']) steerDir = steerDir === -1 ? 0 : 1; // beide gedrückt -> neutral
if (steerDir !== 0) {
const elapsed = currentTime - this.lastSteerChange;
const steps = Math.floor(elapsed / steeringInterval);
if (steps > 0) {
this.taxi.angle += steerDir * steeringAngle * steps;
// Restzeit beibehalten, damit Timing gleichmäßig bleibt
this.lastSteerChange = currentTime - (elapsed % steeringInterval);
}
} else {
// Keine Lenkung gedrückt: Timer resetten, um Burst bei erstem Druck zu vermeiden
this.lastSteerChange = currentTime;
}
// Vorherige Position merken für Richtungs-/Grenzprüfungen
this.prevTaxiX = this.taxi.x;
this.prevTaxiY = this.taxi.y;
// Aktualisiere Position (+20% Geschwindigkeit)
this.taxi.x += Math.cos(this.taxi.angle) * this.taxi.speed * 0.12;
this.taxi.y += Math.sin(this.taxi.angle) * this.taxi.speed * 0.12;
// Aktualisiere aktuelle Tile-Position
this.updateCurrentTile();
// Verbrauche Treibstoff
if (Math.abs(this.taxi.speed) > 0.1) {
this.fuel = Math.max(0, this.fuel - 0.01);
}
// Prüfe auf leeren Tank - Crash wenn Tank leer
if (this.fuel <= 0) {
this.handleEmptyTank();
return; // Stoppe weitere Verarbeitung nach Crash
}
// Prüfe Tanken-Bedingungen
if (this.checkRefuelingConditions()) {
if (!this.isRefueling) {
this.startRefueling();
}
} else {
if (this.isRefueling) {
this.stopRefueling();
}
}
// Motorgeräusch aktualisieren
this.updateMotorSound();
},
handlePassengerActions() {
// WICHTIG: Zuerst absetzen, dann einladen!
// Automatisches Absetzen von geladenen Passagieren
this.checkForPassengerDropoff();
// Automatisches Einladen von wartenden Passagieren
this.checkForWaitingPassengers();
// S - Passagier aufnehmen (manuell)
if (this.keys['s'] || this.keys['S']) {
this.pickupPassenger();
}
// Q - Passagier absetzen
if (this.keys['q'] || this.keys['Q']) {
this.dropoffPassenger();
}
// Enter - Tanken
if (this.keys['Enter']) {
this.refuel();
}
},
checkForWaitingPassengers() {
// Nur einladen wenn das Taxi steht (Geschwindigkeit = 0)
if (this.taxi.speed > 0) return;
// Prüfe alle wartenden Passagiere
for (let i = this.waitingPassengersList.length - 1; i >= 0; i--) {
const passenger = this.waitingPassengersList[i];
// Berechne die Position des Hauses auf dem aktuellen Tile
const housePosition = this.getHousePositionOnTile(passenger.tileX, passenger.tileY, passenger.houseCorner);
if (!housePosition) {
continue;
}
// Prüfe ob das Taxi in der Nähe des Hauses ist
const isNear = this.isTaxiNearHouse(housePosition, passenger.houseCorner);
if (isNear) {
// Lade Passagier ein
this.pickupWaitingPassenger(passenger, i);
break; // Nur einen Passagier pro Frame einladen
}
}
},
getHousePositionOnTile(tileX, tileY, corner) {
// Prüfe ob das Taxi auf dem gleichen Tile ist wie das Haus
if (this.currentTile.col !== tileX || this.currentTile.row !== tileY) {
return null;
}
// Berechne die Position des Hauses basierend auf der Ecke
// Diese Methode muss exakt mit drawHousesOnMainCanvas übereinstimmen
const tileSize = 500; // Tile-Größe
const HOUSE_W = 150; // Hausbreite
const HOUSE_H = 150; // Haushöhe
const pad = 30; // Abstand vom Rand
let houseX, houseY;
switch (corner) {
case 'lo': // links-outer
houseX = pad;
houseY = pad;
break;
case 'ro': // rechts-outer
houseX = tileSize - HOUSE_W - pad;
houseY = pad;
break;
case 'lu': // links-upper
houseX = pad;
houseY = tileSize - HOUSE_H - pad;
break;
case 'ru': // rechts-upper
houseX = tileSize - HOUSE_W - pad;
houseY = tileSize - HOUSE_H - pad;
break;
default:
return null;
}
return { x: houseX, y: houseY, corner };
},
isTaxiNearHouse(housePosition, corner) {
const taxiX = this.taxi.x;
const taxiY = this.taxi.y;
const houseX = housePosition.x;
const houseY = housePosition.y;
const HOUSE_W = 150; // Hausbreite
const HOUSE_H = 150; // Haushöhe
const maxDistance = 100; // 100px Entfernung
// Berechne die Entfernung zur Tile-Mitte
const tileCenterX = 250; // Mitte des 500px Canvas
const tileCenterY = 250; // Mitte des 500px Canvas
const distanceToTileCenter = Math.sqrt(
Math.pow(taxiX - tileCenterX, 2) + Math.pow(taxiY - tileCenterY, 2)
);
// Wenn das Taxi zu weit von der Tile-Mitte entfernt ist, keine Aktion
const maxDistanceFromCenter = 200; // 200px von der Mitte entfernt
if (distanceToTileCenter > maxDistanceFromCenter) {
return false;
}
// Richtungsabhängige Erkennung basierend auf der Haus-Ecke
let isNear = false;
if (corner === 'lo' || corner === 'ro') {
// Horizontale Straße - Taxi muss links oder rechts vom Haus sein
const houseCenterX = houseX + HOUSE_W / 2;
const houseCenterY = houseY + HOUSE_H / 2;
// Prüfe horizontale Distanz (Taxi muss seitlich vom Haus sein)
const horizontalDistance = Math.abs(taxiX - houseCenterX);
const verticalDistance = Math.abs(taxiY - houseCenterY);
// Taxi muss horizontal nah genug sein, aber vertikal kann es weiter weg sein
isNear = horizontalDistance <= maxDistance && verticalDistance <= maxDistance * 1.5;
} else if (corner === 'lu' || corner === 'ru') {
// Vertikale Straße - Taxi muss oberhalb oder unterhalb vom Haus sein
const houseCenterX = houseX + HOUSE_W / 2;
const houseCenterY = houseY + HOUSE_H / 2;
// Prüfe vertikale Distanz (Taxi muss oberhalb/unterhalb vom Haus sein)
const horizontalDistance = Math.abs(taxiX - houseCenterX);
const verticalDistance = Math.abs(taxiY - houseCenterY);
// Taxi muss vertikal nah genug sein, aber horizontal kann es weiter weg sein
isNear = verticalDistance <= maxDistance && horizontalDistance <= maxDistance * 1.5;
}
return isNear;
},
pickupWaitingPassenger(passenger, index) {
// Entferne Passagier aus der Warteliste
this.waitingPassengersList.splice(index, 1);
// Gib das Haus wieder frei
if (passenger.houseId) {
this.occupiedHouses.delete(passenger.houseId);
}
// Generiere ein Ziel für den Passagier
const destination = this.generatePassengerDestination();
if (destination && destination.streetName && destination.streetName !== "Unbekannte Straße") {
// Berechne Bonus und Zeit basierend auf kürzestem Weg
const startX = passenger.tileX;
const startY = passenger.tileY;
const endX = destination.tileX;
const endY = destination.tileY;
const bonusData = this.calculateBonusAndTime(startX, startY, endX, endY);
// Füge Passagier zur geladenen Liste hinzu
const now = Date.now();
this.loadedPassengersList.push({
...passenger,
destination: destination,
pickedUpAt: now,
bonusData: bonusData,
timeLeft: bonusData.maxTime,
lastUpdateTime: 0, // Timer-Aktualisierung erst nach expliziter Aktivierung
timerActive: false // Flag um Timer-Aktivierung zu kontrollieren
});
} else {
}
// Optional: Sound-Effekt oder Nachricht
// this.playPickupSound();
},
checkForPassengerDropoff() {
// Nur absetzen wenn das Taxi steht (Geschwindigkeit = 0)
if (this.taxi.speed > 0) return;
// Prüfe ob geladene Passagiere abgesetzt werden können
if (this.loadedPassengersList.length === 0) return;
// Prüfe alle geladene Passagiere
for (let i = this.loadedPassengersList.length - 1; i >= 0; i--) {
const passenger = this.loadedPassengersList[i];
const destination = passenger.destination;
// Prüfe ob das Taxi am Zielort ist
if (this.currentTile.col === destination.tileX && this.currentTile.row === destination.tileY) {
// Berechne die Position des Zielhauses auf dem aktuellen Tile
const housePosition = this.getHousePositionOnTile(destination.tileX, destination.tileY, destination.houseCorner);
if (housePosition) {
// Prüfe ob das Taxi in der Nähe des Zielhauses ist
const isNear = this.isTaxiNearHouse(housePosition, destination.houseCorner);
if (isNear) {
// Setze Passagier ab
console.log('✅ Passagier abgesetzt:', passenger.name, 'an', destination.location);
this.dropoffLoadedPassenger(passenger, i);
break; // Nur einen Passagier pro Frame absetzen
}
}
}
}
},
dropoffLoadedPassenger(passenger, index) {
// Entferne Passagier aus der geladenen Liste
this.loadedPassengersList.splice(index, 1);
// Berechne Bonus basierend auf verbleibender Zeit
let bonus = 0;
if (passenger.bonusData && passenger.timeLeft > 0) {
// Bonus wird nur vergeben, wenn noch Zeit übrig ist
bonus = passenger.bonusData.bonus;
}
// Belohne den Spieler
this.passengersDelivered++;
this.score += 50 + bonus;
this.money += 25 + Math.floor(bonus / 2);
console.log(`Passagier abgesetzt: ${passenger.name}, Bonus: ${bonus}, Zeit übrig: ${passenger.timeLeft}s`);
// Optional: Sound-Effekt oder Nachricht
// this.playDropoffSound();
},
pickupPassenger() {
// Finde nächsten Passagier in der Nähe
for (let i = 0; i < this.passengers.length; i++) {
const passenger = this.passengers[i];
if (!passenger.pickedUp && this.checkCollision(this.taxi, passenger)) {
passenger.pickedUp = true;
this.score += 10;
break;
}
}
},
dropoffPassenger() {
// Finde nächstes Ziel in der Nähe
for (let i = 0; i < this.destinations.length; i++) {
const destination = this.destinations[i];
if (!destination.completed && this.checkCollision(this.taxi, destination)) {
destination.completed = true;
this.passengersDelivered++;
this.score += 50;
this.money += 25;
// Entferne Passagier aus der Warteliste und gib Haus frei
this.removePassengerFromWaitingList();
break;
}
}
},
refuel() {
// Prüfe ob Tankstellen verfügbar sind
if (!this.gasStations || this.gasStations.length === 0) {
console.log('Keine Tankstellen verfügbar');
return;
}
// Finde nächste Tankstelle in der Nähe
for (let i = 0; i < this.gasStations.length; i++) {
const station = this.gasStations[i];
if (this.checkCollision(this.taxi, station)) {
this.fuel = Math.min(100, this.fuel + 50);
this.score += 5;
break;
}
}
},
checkCollisions() {
// Prüfe Straßenkollisionen nur wenn das Spiel nicht pausiert ist
if (!this.isPaused && !this.isTaxiOnRoad()) {
this.handleCrash();
}
// Rotlichtverstöße prüfen
if (!this.isPaused) {
if (!this.skipRedLightOneFrame) {
this.checkRedLightViolation();
}
// Einmal-Flag nach der Prüfung zurücksetzen
if (this.skipRedLightOneFrame) this.skipRedLightOneFrame = false;
}
// Prüfe Hindernisse nur wenn das Spiel nicht pausiert ist
if (!this.isPaused) {
this.obstacles.forEach(obstacle => {
if (this.checkCollision(this.taxi, obstacle, true)) {
this.handleCrash();
}
});
// Prüfe Autos-Kollisionen
this.cars.forEach(car => {
if (this.checkCollision(this.taxi, car, true)) {
this.handleCrash('auto');
}
});
}
},
// Prüft Überfahren der (virtuell verdoppelten) Haltelinie aus der straßenzugewandten Seite
checkRedLightViolation() {
if (!this.currentMap || !this.currentMap.mapData) return;
const tileSize = this.tiles.size;
const tileType = this.getCurrentTileType();
const approaches = this.getTileApproaches(tileType);
if (!approaches.top && !approaches.right && !approaches.bottom && !approaches.left) return;
if (!this.getTrafficLightFor(this.currentTile.col + (this.currentMap.offsetX||0), this.currentTile.row + (this.currentMap.offsetY||0))) return;
const phase = this.getTrafficLightPhase(this.currentTile.col + (this.currentMap.offsetX||0), this.currentTile.row + (this.currentMap.offsetY||0));
const rects = this.getVirtualStopLineRects(tileSize, approaches);
// Kleine Toleranz für Segmenttreffer (dick=5px, daher +-4px ausreichend)
const tol = 4;
// Einseitige Prüfung je Richtung und Phasen-Logik
// Zu prüfende Ecken laut Vorgabe:
// Horizontal: links unten (BL) und rechts oben (TR) müssen rot sein, um Verstoß zu zählen
// Vertikal: rechts unten (BR) und links oben (TL) müssen rot sein
// Diagonale Synchronisierung: TL/BR sind vertikal gekoppelt, TR/BL horizontal
const isHorRed = (phase === 2 || phase === 3);
const isVerRed = (phase === 0 || phase === 1);
let violated = false;
// Von welcher Seite kommt das Taxi? Delta der Mittelpunkt-Position
const prevCX = this.prevTaxiX + this.taxi.width / 2;
const prevCY = this.prevTaxiY + this.taxi.height / 2;
const currCX = this.taxi.x + this.taxi.width / 2;
const currCY = this.taxi.y + this.taxi.height / 2;
const vx = currCX - prevCX;
const vy = currCY - prevCY;
// Hinweis: Keine globale Bewegungsschwelle mehr.
// Die Richtungsbedingungen (vy/vx > 0 bzw. < 0) in den jeweiligen Fällen
// stellen bereits sicher, dass nur bei tatsächlicher Bewegung geprüft wird.
// Top-Band: von oben nach unten (prev oben, curr unten), Eintritt über obere Bandkante y0
if (rects.top && approaches.top) {
const x0 = rects.top.x, x1 = rects.top.x + rects.top.width;
const y0 = rects.top.y, y1 = rects.top.y + rects.top.height;
// Für horizontale Haltelinien: Y-Position muss in der Nähe der Haltelinie sein
const isNearStopLine = currCY >= (y0 - 50) && currCY <= (y1 + 50);
// Zusätzlich: innerhalb des horizontalen Liniensegments (X-Span) sein
const withinXSpan = (
(prevCX >= x0 - tol && prevCX <= x1 + tol) ||
(currCX >= x0 - tol && currCX <= x1 + tol) ||
// oder Strecke zwischen prevCX und currCX schneidet [x0, x1]
(Math.min(prevCX, currCX) <= x1 + tol && Math.max(prevCX, currCX) >= x0 - tol)
);
if (vy > 0 && prevCY < y0 && currCY >= y0 && isVerRed && isNearStopLine && withinXSpan) {
violated = true;
}
}
// Bottom-Band: von unten nach oben (prev unten, curr oben), Eintritt über untere Bandkante y1
if (rects.bottom && approaches.bottom) {
const x0 = rects.bottom.x, x1 = rects.bottom.x + rects.bottom.width;
const y0 = rects.bottom.y, y1 = rects.bottom.y + rects.bottom.height;
// Für horizontale Haltelinien: Y-Position muss in der Nähe der Haltelinie sein
const isNearStopLine = currCY >= (y0 - 50) && currCY <= (y1 + 50);
// Zusätzlich: innerhalb des horizontalen Liniensegments (X-Span) sein
const withinXSpan = (
(prevCX >= x0 - tol && prevCX <= x1 + tol) ||
(currCX >= x0 - tol && currCX <= x1 + tol) ||
(Math.min(prevCX, currCX) <= x1 + tol && Math.max(prevCX, currCX) >= x0 - tol)
);
if (vy < 0 && prevCY > y1 && currCY <= y1 && isVerRed && isNearStopLine && withinXSpan) {
violated = true;
}
}
// Left-Band: von links nach rechts (prev links, curr rechts), Eintritt über linke Bandkante x0
if (rects.left && approaches.left) {
const x0 = rects.left.x, x1 = rects.left.x + rects.left.width;
const y0 = rects.left.y, y1 = rects.left.y + rects.left.height;
// Für vertikale Haltelinien: X-Position muss in der Nähe der Haltelinie sein
const isNearStopLine = currCX >= (x0 - 50) && currCX <= (x1 + 50);
// Zusätzlich: innerhalb des vertikalen Liniensegments (Y-Span) sein
const withinYSpan = (
(prevCY >= y0 - tol && prevCY <= y1 + tol) ||
(currCY >= y0 - tol && currCY <= y1 + tol) ||
(Math.min(prevCY, currCY) <= y1 + tol && Math.max(prevCY, currCY) >= y0 - tol)
);
if (vx > 0 && prevCX < x0 && currCX >= x0 && isHorRed && isNearStopLine && withinYSpan) {
violated = true;
}
}
// Right-Band: von rechts nach links (prev rechts, curr links), Eintritt über rechte Bandkante x1
if (rects.right && approaches.right) {
const x0 = rects.right.x, x1 = rects.right.x + rects.right.width;
const y0 = rects.right.y, y1 = rects.right.y + rects.right.height;
// Für vertikale Haltelinien: X-Position muss in der Nähe der Haltelinie sein
const isNearStopLine = currCX >= (x0 - 50) && currCX <= (x1 + 50);
// Zusätzlich: innerhalb des vertikalen Liniensegments (Y-Span) sein
const withinYSpan = (
(prevCY >= y0 - tol && prevCY <= y1 + tol) ||
(currCY >= y0 - tol && currCY <= y1 + tol) ||
(Math.min(prevCY, currCY) <= y1 + tol && Math.max(prevCY, currCY) >= y0 - tol)
);
if (vx < 0 && prevCX > x1 && currCX <= x1 && isHorRed && isNearStopLine && withinYSpan) {
violated = true;
}
}
if (violated) {
// Entprellen: pro Tile nur einmal pro tatsächlichem Übertritt zählen
const key = `${this.currentTile.col},${this.currentTile.row}`;
const now = Date.now();
const last = this.redLightLastCount[key] || 0;
console.log('Violation detected! Key:', key, 'Last:', last, 'Now:', now, 'Diff:', now - last);
if (now - last > 500) { // 0.5s Sperrzeit
console.log('✅ VIOLATION REGISTERED! Count:', this.redLightViolations + 1);
this.redLightLastCount[key] = now;
this.redLightViolations += 1;
this.playRedLightSound();
// Drei Rotlichtverstöße => Crash-Penalty
this.redLightSincePenalty = (this.redLightSincePenalty || 0) + 1;
if (this.redLightSincePenalty >= 3) {
this.redLightSincePenalty = 0;
this.countCrash('redlight');
const msg = 'Du hast wegen Rotlicht-Übertretungen ein Auto verloren.';
const title = 'Hinweis';
this.$root?.$refs?.messageDialog?.open?.(msg, title, {}, () => {
if (this.vehicleCount === 0) {
this.handleOutOfVehicles();
}
});
}
}
}
},
checkRadarMeasurement() {
if (!this.activeRadar || this.radarTicketShown || !this.radarAxis) return;
const size = this.tiles.size;
const thresholdSpeedLevel = 10; // >50 km/h
const speedLevel = this.taxi.speed;
const prevCX = this.prevTaxiX + this.taxi.width / 2;
const prevCY = this.prevTaxiY + this.taxi.height / 2;
const currCX = this.taxi.x + this.taxi.width / 2;
const currCY = this.taxi.y + this.taxi.height / 2;
if (this.radarAxis === 'H') {
const y = this.radarLinePos;
// Richtung beachten: von oben nach unten über y (wenn y nahe oben), sonst umgekehrt
if (y <= size / 2) {
if (speedLevel > thresholdSpeedLevel && prevCY < y && currCY >= y) this.issueSpeedTicket();
} else {
if (speedLevel > thresholdSpeedLevel && prevCY > y && currCY <= y) this.issueSpeedTicket();
}
} else if (this.radarAxis === 'V') {
const x = this.radarLinePos;
if (x <= size / 2) {
if (speedLevel > thresholdSpeedLevel && prevCX < x && currCX >= x) this.issueSpeedTicket();
} else {
if (speedLevel > thresholdSpeedLevel && prevCX > x && currCX <= x) this.issueSpeedTicket();
}
}
},
issueSpeedTicket() {
this.radarTicketShown = true;
this.speedViolations += 1;
this.playCameraSound();
},
playCameraSound() {
try {
this.ensureAudioUnlockedInEvent();
if (!this.audioContext) return;
const now = this.audioContext.currentTime;
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
// kurzer Klick (Kamera-Shutter)
osc.type = 'square';
osc.frequency.setValueAtTime(2400, now);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.4, now + 0.005);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.06);
osc.connect(gain).connect(this.audioContext.destination);
osc.start(now);
osc.stop(now + 0.07);
} catch (e) {
// ignore
}
},
playRedLightSound() {
// Ein kurzer Piepton über die bestehende AudioContext-Pipeline
try {
this.ensureAudioUnlockedInEvent();
if (!this.audioContext) return;
const now = this.audioContext.currentTime;
// Entprellen global: max 1x pro 550ms (Ton ist länger)
const wall = (this._rlLastWall || 0);
if (now < wall) return;
this._rlLastWall = now + 0.55;
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square';
// etwas höher
osc.frequency.setValueAtTime(1320, now);
gain.gain.setValueAtTime(0, now);
// etwas lauter und länger
gain.gain.linearRampToValueAtTime(0.35, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.5);
osc.connect(gain).connect(this.audioContext.destination);
osc.start(now);
osc.stop(now + 0.52);
} catch (e) {
// ignore
}
},
countCrash(reason = '') {
this.crashes++;
this.decrementVehicle(reason);
this.taxi.speed = 0;
this.isPaused = true; // Zuerst pausieren
this.showPauseOverlay = true; // Overlay sichtbar machen
// Alle KI-Autos sofort entfernen
try { this.cars = []; } catch (_) {}
// Game Loop stoppen
if (this.gameLoop) {
clearTimeout(this.gameLoop);
this.gameLoop = null;
}
// Motor sofort stoppen
try { if (this.motorSound && this.motorSound.isPlaying) this.motorSound.stop(); } catch (_) {}
// Eingaben zurücksetzen, sonst bleibt "beschleunigen" aus
this.keys = {};
this.lastSpeedChange = 0;
this.fuel = 100;
},
handleCrash() {
// Verhindere mehrfache Crashes in kurzer Zeit
if (this.isPaused) {
console.log('Crash bereits erkannt, ignoriere weitere');
return;
}
console.log('🚨 CRASH ERKANNT!');
this.countCrash('wall crashed');
// Motorgeräusch sofort stoppen
if (this.motorSound && this.motorSound.isPlaying) {
this.motorSound.stop();
}
// Taxi mittig im aktuellen Tile platzieren
this.taxi.speed = 0;
this.taxi.angle = 0;
this.centerTaxiInCurrentTile();
// Dialog über globale MessageDialog öffnen
this.$nextTick(() => {
// Temporär direkte Übersetzung verwenden, bis i18n-Problem gelöst ist
const crashMessage = `Du hattest einen Unfall! Crashes: ${this.crashes}`;
const crashTitle = 'Unfall!';
this.$root?.$refs?.messageDialog?.open?.(crashMessage, crashTitle, {}, this.handleCrashDialogClose);
// Test: Direkter Aufruf nach 3 Sekunden (falls Dialog-Callback nicht funktioniert)
this.crashDialogTimeout = setTimeout(() => {
this.handleCrashDialogClose();
}, 3000);
console.log('Crash-Dialog wird angezeigt:', {
crashes: this.crashes,
isPaused: this.isPaused,
taxiSpeed: this.taxi.speed
});
// Spiel bleibt pausiert bis Dialog geschlossen wird
});
},
handleCrashDialogClose() {
console.log('Crash-Dialog geschlossen, Spiel wird fortgesetzt');
this.isPaused = false;
this.showPauseOverlay = false;
console.log('Nach Dialog-Close - isPaused:', this.isPaused, 'showPauseOverlay:', this.showPauseOverlay);
// Game Loop sicher neu starten (unabhängig vom vorherigen Zustand)
this.gameRunning = true;
// Lösche alle alten Autos auch hier (falls welche existieren)
this.cars = [];
console.log('[🚗 GAME] Cleared all cars after dialog close');
try { clearTimeout(this.gameLoop); } catch (_) {}
this.gameLoop = setTimeout(this.update, 62);
// Taxi bleibt auf dem aktuellen Tile, mittig platzieren
this.taxi.speed = 0;
this.taxi.angle = 0;
this.centerTaxiInCurrentTile();
// Eingabestatus zurücksetzen, damit Beschleunigen wieder funktioniert
this.keys = {};
this.lastSpeedChange = 0;
// Key-Listener frisch registrieren und Fokus auf Canvas erzwingen
try { document.removeEventListener('keydown', this.handleKeyDown); } catch (_) {}
document.addEventListener('keydown', this.handleKeyDown);
try { this.canvas && this.canvas.focus && this.canvas.focus(); } catch (_) {}
// Motor neu initialisieren, falls erforderlich
try {
if (this.motorSound && this.motorSound.isPlaying) this.motorSound.stop();
if (this.motorSound && !this.motorSound.isInitialized && this.audioContext) {
this.motorSound.init();
}
} catch (_) {}
// Fokus zurück auf Canvas setzen
this.$nextTick(() => {
if (this.canvas) {
this.canvas.focus();
}
console.log('Nach nextTick - isPaused:', this.isPaused);
if (this.vehicleCount === 0) {
this.handleOutOfVehicles();
}
});
},
decrementVehicle(reason = '') {
this.vehicleCount = Math.max(0, this.vehicleCount - 1);
},
getPlayTime() {
if (!this.gameStartTime) return 0;
return Math.floor((Date.now() - this.gameStartTime) / 1000);
},
async saveHighscore() {
try {
const playTime = this.getPlayTime();
const highscoreData = {
passengersDelivered: this.passengersDelivered,
playtime: playTime,
points: this.score,
mapId: this.currentMap ? this.currentMap.id : null
};
console.log('Highscore-Daten:', highscoreData);
console.log('Current Map:', this.currentMap);
console.log('Passengers Delivered:', this.passengersDelivered);
console.log('Playtime:', playTime);
console.log('Points:', this.score);
const response = await apiClient.post('/api/taxi/highscore', highscoreData);
if (response.data.success) {
console.log('Highscore erfolgreich gespeichert:', response.data.data);
return response.data.data;
} else {
console.error('Fehler beim Speichern des Highscores:', response.data.message);
return null;
}
} catch (error) {
console.error('Fehler beim Speichern des Highscores:', error);
if (error.response) {
console.error('Backend-Fehler:', error.response.data);
console.error('Status:', error.response.status);
}
return null;
}
},
async handleOutOfVehicles() {
// Spiel stoppen
this.isPaused = true;
this.gameLoop = null;
// Highscore speichern
const highscore = await this.saveHighscore();
const playTime = this.getPlayTime();
const playTimeMinutes = Math.floor(playTime / 60);
const playTimeSeconds = playTime % 60;
const title = 'Spiel beendet!';
const msg = `Keine Fahrzeuge mehr. Spiel beendet!\n\n` +
`Deine Leistung:\n` +
`• Passagiere: ${this.passengersDelivered}\n` +
`• Punkte: ${this.score}\n` +
`• Spielzeit: ${playTimeMinutes}:${playTimeSeconds.toString().padStart(2, '0')}\n` +
`• Map: ${this.currentMap ? this.currentMap.name : 'Unbekannt'}\n\n` +
`Highscore wurde gespeichert!`;
this.$root?.$refs?.messageDialog?.open?.(msg, title, {}, () => {
this.restartLevel();
this.vehicleCount = 5;
this.crashes = 0;
this.redLightViolations = 0;
this.redLightSincePenalty = 0;
this.gameStartTime = null;
});
},
resetTaxiPosition() {
// Taxi in der Mitte des Screens platzieren
this.taxi.x = 250 - this.taxi.width/2;
this.taxi.y = 250 - this.taxi.height/2;
this.taxi.speed = 0;
this.taxi.angle = 0;
// Aktuelle Tile-Position zurücksetzen
this.currentTile.row = 0;
this.currentTile.col = 0;
},
centerTaxiInCurrentTile() {
// Taxi mittig im aktuellen Tile platzieren (ohne Tile-Position zu ändern)
this.taxi.x = 250 - this.taxi.width/2; // Mitte des Canvas (500px / 2)
this.taxi.y = 250 - this.taxi.height/2; // Mitte des Canvas (500px / 2)
this.taxi.speed = 0;
this.taxi.angle = 0;
// Prev-Position synchronisieren und erste Prüfung aussetzen
this.prevTaxiX = this.taxi.x;
this.prevTaxiY = this.taxi.y;
this.skipRedLightOneFrame = true;
},
isTaxiOnRoad() {
// Prüfe ob das Taxi innerhalb der Canvas-Grenzen ist
if (this.taxi.speed < 1) {
return true;
}
if (this.taxi.x < 0 || this.taxi.x >= this.canvas.width ||
this.taxi.y < 0 || this.taxi.y >= this.canvas.height) {
return false;
}
// Hole das aktuelle Tile und dessen Polygone
const tileType = this.getCurrentTileType();
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
const originalTileSize = streetCoordinates.getOriginalTileSize(); // 640px
const currentTileSize = 500; // Aktuelle Render-Größe
// Hole die relativen Koordinaten aus der JSON (0-1) und konvertiere zu absoluten Pixeln
const tileData = streetCoordinates.data.tiles[streetTileType];
const regions = tileData ? tileData.regions : [];
if (regions.length === 0) {
// Keine Polygone definiert = befahrbar
return true;
}
// Erstelle Taxi-Rechteck in absoluten Pixel-Koordinaten (500x500px)
const taxiRect = {
x: this.taxi.x,
y: this.taxi.y,
width: this.taxi.width,
height: this.taxi.height
};
// Alle Tiles verwenden die gleiche Logik: Prüfe ob Taxi eine der Hindernis-Polylinien schneidet
// Erstelle rotiertes Rechteck in absoluten Pixel-Koordinaten (500x500px)
const rotatedRect = {
cx: taxiRect.x + taxiRect.width / 2, // Mittelpunkt X
cy: taxiRect.y + taxiRect.height / 2, // Mittelpunkt Y
theta: this.taxi.imageAngle + this.taxi.angle, // Rotation
hx: taxiRect.width / 2, // halbe Breite
hy: taxiRect.height / 2 // halbe Höhe
};
// Prüfe jede Region (Polylinie) einzeln
for (let i = 0; i < regions.length; i++) {
const region = regions[i];
// Jede Region ist eine Polylinie mit mehreren Punkten
if (region.length >= 2) {
// Konvertiere relative Koordinaten (0-1) zu absoluten Pixeln (500x500px)
const regionPoints = region.map(point => ({
x: point.x * currentTileSize, // Konvertiere von 0-1 zu 0-500px
y: point.y * currentTileSize // Konvertiere von 0-1 zu 0-500px
}));
// Verwende die robuste Kollisionserkennung für diese Region
const result = this.polylineIntersectsRotatedRect(regionPoints, rotatedRect);
// Einheitliche Logik für alle Tiles: Taxi darf KEINE der Hindernis-Polylinien schneiden
if (result.hit) {
return false; // Kollision mit Hindernis = Crash
}
}
}
// Alle Regionen geprüft - einheitliche Logik für alle Tiles
// Keine Hindernis-Polylinie geschnitten = befahrbar
return true; // Keine Kollision = befahrbar
},
// Prüft ob eine Linie ein rotiertes Rechteck schneidet
isLineCrossingRect(rect, line) {
// Verwende absolute Pixel-Koordinaten (500x500px)
const currentTileSize = 500; // Aktuelle Render-Größe
// Verwende Rechteck direkt in absoluten Koordinaten
const absoluteRect = {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
};
// Konvertiere Linien-Koordinaten von 640px zu absoluten Pixeln (500x500px)
const absoluteLine = {
x1: (line.x1 / 640) * currentTileSize,
y1: (line.y1 / 640) * currentTileSize,
x2: (line.x2 / 640) * currentTileSize,
y2: (line.y2 / 640) * currentTileSize
};
// Erstelle Polyline aus der absoluten Linie
const polyline = [
{ x: absoluteLine.x1, y: absoluteLine.y1 },
{ x: absoluteLine.x2, y: absoluteLine.y2 }
];
// Erstelle rotiertes Rechteck in absoluten Koordinaten (500x500px)
const rotatedRect = {
cx: absoluteRect.x + absoluteRect.width / 2, // Mittelpunkt X
cy: absoluteRect.y + absoluteRect.height / 2, // Mittelpunkt Y
theta: this.taxi.imageAngle + this.taxi.angle, // Rotation
hx: absoluteRect.width / 2, // halbe Breite
hy: absoluteRect.height / 2 // halbe Höhe
};
// Verwende die robuste Kollisionserkennung
const result = this.polylineIntersectsRotatedRect(polyline, rotatedRect);
return result.hit;
},
// Testet, ob eine Polyline (Liste von Punkten) ein rotiertes Rechteck schneidet
polylineIntersectsRotatedRect(polyline, rect) {
const { cx, cy, theta, hx, hy } = rect;
// Transformiere die Polyline in das lokale Koordinatensystem des Rechtecks
const localPolyline = polyline.map(p => this.transformToLocal(p.x, p.y, cx, cy, theta));
// AABB-Grenzen im lokalen Koordinatensystem
const AABB_minX = -hx;
const AABB_maxX = hx;
const AABB_minY = -hy;
const AABB_maxY = hy;
// 1. Prüfe, ob transformierte Polyline-Punkte in der AABB liegen
for (let i = 0; i < localPolyline.length; i++) {
const p = localPolyline[i];
const inX = p.x >= AABB_minX && p.x <= AABB_maxX;
const inY = p.y >= AABB_minY && p.y <= AABB_maxY;
if (inX && inY) {
return { hit: true, type: 'point_in_rect' };
}
}
// 2. Prüfe auf Segment-Schnitt mit der AABB im lokalen System
for (let i = 0; i < localPolyline.length - 1; i++) {
const A = localPolyline[i];
const B = localPolyline[i + 1];
if (this.lineSegmentIntersectsRect(A.x, A.y, B.x, B.y, AABB_minX, AABB_minY, AABB_maxX, AABB_maxY)) {
return { hit: true, type: 'segment_intersects_rect' };
}
}
return { hit: false };
},
/**
* Transformiert einen Punkt in das lokale, achsparallele Koordinatensystem des Rechtecks.
* @param {number} px - X-Koordinate des Punktes
* @param {number} py - Y-Koordinate des Punktes
* @param {number} cx - X-Koordinate des Rechteckmittelpunkts
* @param {number} cy - Y-Koordinate des Rechteckmittelpunkts
* @param {number} theta - Rotationswinkel des Rechtecks (in Radiant)
* @returns {Object} Transformierter Punkt {x, y} im lokalen Koordinatensystem
*/
transformToLocal(px, py, cx, cy, theta) {
// 1. Translation: Verschieben des Rechteckmittelpunkts in den Ursprung (0,0)
const tx = px - cx;
const ty = py - cy;
// 2. Rotation: Gegen-Rotation um -theta
const cos = Math.cos(-theta);
const sin = Math.sin(-theta);
const lx = tx * cos - ty * sin;
const ly = tx * sin + ty * cos;
return { x: lx, y: ly };
},
// Prüft ob ein Punkt auf einem Liniensegment liegt
isPointOnLineSegment(px, py, x1, y1, x2, y2, tolerance = 0.01) {
// Berechne den Abstand vom Punkt zur Linie
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
if (lenSq === 0) {
// Linie ist ein Punkt
return Math.abs(px - x1) < tolerance && Math.abs(py - y1) < tolerance;
}
const param = dot / lenSq;
// Prüfe ob Parameter im Bereich [0,1] liegt
if (param < 0 || param > 1) {
return false;
}
// Berechne den nächsten Punkt auf der Linie
const xx = x1 + param * C;
const yy = y1 + param * D;
// Prüfe ob der Abstand klein genug ist
const distance = Math.sqrt((px - xx) * (px - xx) + (py - yy) * (py - yy));
return distance <= tolerance;
},
// Prüft ob ein Liniensegment ein Rechteck schneidet
lineSegmentIntersectsRect(x1, y1, x2, y2, rectMinX, rectMinY, rectMaxX, rectMaxY) {
// Verwende den Liang-Barsky Algorithmus für Liniensegment-Rechteck Schnitt
let t0 = 0, t1 = 1;
const dx = x2 - x1;
const dy = y2 - y1;
// Prüfe jede Seite des Rechtecks
const sides = [
{ p: -dx, q: x1 - rectMinX }, // links
{ p: dx, q: rectMaxX - x1 }, // rechts
{ p: -dy, q: y1 - rectMinY }, // oben
{ p: dy, q: rectMaxY - y1 } // unten
];
for (const side of sides) {
if (Math.abs(side.p) < 1e-9) {
// Parallel zur Seite
if (side.q < 0) {
return false; // außerhalb
}
} else {
const t = side.q / side.p;
if (side.p < 0) {
// Eintritt
if (t > t0) t0 = t;
} else {
// Austritt
if (t < t1) t1 = t;
}
if (t0 > t1) {
return false; // keine Überschneidung
}
}
}
return t0 <= 1 && t1 >= 0;
},
// Prüft ob ein Punkt auf einer Linie liegt
isPointOnLine(px, py, line) {
const { x1, y1, x2, y2 } = line;
// Berechne den Abstand vom Punkt zur Linie
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
if (lenSq === 0) {
// Linie ist ein Punkt
return Math.abs(px - x1) < 0.001 && Math.abs(py - y1) < 0.001;
}
const param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < 0.001; // Toleranz für Gleitkommazahlen
},
// Prüft ob ein Punkt in einem Polygon liegt (Point-in-Polygon)
isPointInPolygon(x, y, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
// Ray casting algorithm: Prüfe ob der Strahl von (x,y) nach rechts die Kante schneidet
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
},
// Prüft ob ein Punkt in einem Rechteck liegt
isPointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width &&
y >= rect.y && y <= rect.y + rect.height;
},
// Prüft ob zwei Linien sich schneiden
lineIntersection(line1, line2) {
const x1 = line1.x1, y1 = line1.y1, x2 = line1.x2, y2 = line1.y2;
const x3 = line2.x1, y3 = line2.y1, x4 = line2.x2, y4 = line2.y2;
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (Math.abs(denom) < 1e-10) return false; // Parallele Linien
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
},
getCurrentTileType() {
// Hole das aktuelle Tile aus der geladenen Map
if (this.currentMap && this.currentMap.mapData) {
const mapData = this.currentMap.mapData;
const tile = mapData[this.currentTile.row] && mapData[this.currentTile.row][this.currentTile.col];
// Prüfe ob tile ein String ist (direkter Tile-Name) oder ein Objekt mit type-Eigenschaft
if (tile) {
if (typeof tile === 'string') {
return tile;
} else if (tile.type) {
return tile.type;
}
}
}
// Fallback: Standard-Tile
return 'cornertopleft';
},
checkCollision(a, b, recordDebug = false) {
// Präzise Kollision über OBB (oriented bounding boxes) mittels SAT
// Fallback auf AABB, wenn Winkel fehlen
const angleA = ((typeof a.angle === 'number') ? a.angle : 0) + ((typeof a.imageAngle === 'number') ? a.imageAngle : 0);
const angleB = ((typeof b.angle === 'number') ? b.angle : 0) + ((typeof b.imageAngle === 'number') ? b.imageAngle : 0);
// Wenn beide quasi unrotiert sind, nutze performantes AABB
const small = 1e-6;
if (Math.abs(angleA) < small && Math.abs(angleB) < small) {
const pad = (a.isCar || b.isCar) ? 4 : 0;
const aLeft = a.x + pad;
const aRight = a.x + a.width - pad;
const aTop = a.y + pad;
const aBottom = a.y + a.height - pad;
const bLeft = b.x + pad;
const bRight = b.x + b.width - pad;
const bTop = b.y + pad;
const bBottom = b.y + b.height - pad;
const hit = aLeft < bRight && aRight > bLeft && aTop < bBottom && aBottom > bTop;
if (recordDebug) {
this._collisionDebug = {
aCorners: [
{x:aLeft,y:aTop},{x:aRight,y:aTop},{x:aRight,y:aBottom},{x:aLeft,y:aBottom}
],
bCorners: [
{x:bLeft,y:bTop},{x:bRight,y:bTop},{x:bRight,y:bBottom},{x:bLeft,y:bBottom}
],
ttl: 60
};
}
return hit;
}
const pad = (a.isCar || b.isCar) ? 3 : 0; // etwas größerer Puffer bei OBB
const obbA = this._buildObb(a, pad);
const obbB = this._buildObb(b, pad);
// Achsen: Normalen der Kanten beider Rechtecke (4 Achsen)
const axes = [
this._edgeAxis(obbA.corners[0], obbA.corners[1]),
this._edgeAxis(obbA.corners[1], obbA.corners[2]),
this._edgeAxis(obbB.corners[0], obbB.corners[1]),
this._edgeAxis(obbB.corners[1], obbB.corners[2])
];
for (let i = 0; i < axes.length; i++) {
const axis = axes[i];
const projA = this._projectOntoAxis(obbA.corners, axis);
const projB = this._projectOntoAxis(obbB.corners, axis);
if (projA.max < projB.min || projB.max < projA.min) {
return false; // trennende Achse gefunden
}
}
if (recordDebug) {
this._collisionDebug = { aCorners: obbA.corners, bCorners: obbB.corners, ttl: 60 };
}
return true; // alle Intervalle überlappen
},
_buildObb(rect, pad = 0) {
const cx = rect.x + rect.width / 2;
const cy = rect.y + rect.height / 2;
// Spezifische Kollisionsmaße:
// - Taxi: schmaler als Zeichnungs-Bounding-Box (Bild enthält weiße Ränder)
// - Auto: verwende reale width/height
let w = rect.width;
let h = rect.height;
if (rect === this.taxi) {
w = Math.max(0, rect.width * 0.70); // 70% der visuellen Breite
h = Math.max(0, rect.height * 0.90); // 90% der visuellen Höhe
} else if (rect.isCar) {
// Autos fahren nur links/rechts → Fahrzeuglänge = horizontal (w), Fahrzeugbreite = vertikal (h)
// Daher: Länge kaum kürzen, Breite deutlich schmaler
w = Math.max(0, rect.width * 0.90); // Länge ~90%
h = Math.max(0, rect.height * 0.57); // Breite ~65%
}
const hw = Math.max(0, w / 2 - pad);
const hh = Math.max(0, h / 2 - pad);
const ang = ((rect.angle || 0) + (rect.imageAngle || 0));
const c = Math.cos(ang);
const s = Math.sin(ang);
// lokale Ecken
const local = [
{ x: -hw, y: -hh },
{ x: hw, y: -hh },
{ x: hw, y: hh },
{ x: -hw, y: hh }
];
// rotiere + verschiebe
const corners = local.map(p => ({ x: cx + p.x * c - p.y * s, y: cy + p.x * s + p.y * c }));
return { corners };
},
_edgeAxis(p1, p2) {
// Kantenrichtung
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
// Normale (senkrecht)
const nx = -dy;
const ny = dx;
// normieren
const len = Math.hypot(nx, ny) || 1;
return { x: nx / len, y: ny / len };
},
_projectOntoAxis(corners, axis) {
let min = Infinity, max = -Infinity;
for (let i = 0; i < corners.length; i++) {
const p = corners[i];
const val = p.x * axis.x + p.y * axis.y;
if (val < min) min = val;
if (val > max) max = val;
}
return { min, max };
},
render() {
// Lösche Canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Zeichne Straßen
this.drawRoads();
// Radar-Icon oben rechts (50x50), wenn aktiv
if (this.activeRadar) {
const img = this.tiles.images['radar'];
if (img && img.complete) {
const size = 50;
this.ctx.drawImage(img, this.canvas.width - size - 4, 4, size, size);
}
}
// Zeichne Hindernisse
this.obstacles.forEach(obstacle => {
this.ctx.fillStyle = '#666';
this.ctx.fillRect(obstacle.x, obstacle.y, obstacle.width, obstacle.height);
});
// Passagiere/Ziele werden aktuell nicht gezeichnet
// Zeichne Autos
this.drawCars();
// Debug: Kollision-Overlays zeichnen (falls vorhanden)
if (this._collisionDebug && this._collisionDebug.ttl > 0) {
const dbg = this._collisionDebug;
this._drawDebugPoly(dbg.aCorners, 'rgba(255,0,0,0.35)', '#ff0000');
this._drawDebugPoly(dbg.bCorners, 'rgba(0,128,255,0.35)', '#0080ff');
dbg.ttl -= 1;
}
// Zeichne Taxi
this.ctx.save();
this.ctx.translate(this.taxi.x + this.taxi.width/2, this.taxi.y + this.taxi.height/2);
this.ctx.rotate(this.taxi.imageAngle + this.taxi.angle);
if (this.taxi.image) {
// Zeichne Taxi-Bild
// console.log('Zeichne Taxi-Bild:', this.taxi.image, 'Position:', this.taxi.x, this.taxi.y);
this.ctx.drawImage(
this.taxi.image,
-this.taxi.width/2,
-this.taxi.height/2,
this.taxi.width,
this.taxi.height
);
} else {
// Fallback: Zeichne blaues Rechteck wenn Bild nicht geladen
// console.log('Taxi-Bild nicht geladen, zeichne Fallback-Rechteck');
this.ctx.fillStyle = '#2196F3';
this.ctx.fillRect(-this.taxi.width/2, -this.taxi.height/2, this.taxi.width, this.taxi.height);
}
this.ctx.restore();
},
// Zeichne alle Autos
drawCars() {
console.log(`[🚗 DRAW] Drawing ${this.cars.length} cars`);
this.cars.forEach((car, index) => {
const centerX = car.x + car.width/2;
const centerY = car.y + car.height/2;
// Auto ist sichtbar wenn IRGENDEIN Teil im Canvas ist (nicht nur das Zentrum)
const isVisible = !(car.x + car.width < 0 || car.x > 500 || car.y + car.height < 0 || car.y > 500);
console.log(`[🚗 DRAW] Car ${index}: x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}, centerX=${centerX.toFixed(2)}, centerY=${centerY.toFixed(2)}, direction=${car.direction}, speed=${car.speed.toFixed(2)}, VISIBLE=${isVisible}`);
this.ctx.save();
// Auto-Position als linke obere Ecke (car.x, car.y ist die linke obere Ecke)
// Transformiere zum Zentrum des Autos für Rotation
this.ctx.translate(centerX, centerY);
// Rotiere basierend auf der Fahrtrichtung (nicht angle)
let rotationAngle = 0;
switch (car.direction) {
case 'up':
rotationAngle = -Math.PI / 2;
break;
case 'down':
rotationAngle = Math.PI / 2;
break;
case 'left':
rotationAngle = Math.PI;
break;
case 'right':
rotationAngle = 0;
break;
}
// Auto-Bild korrigieren (90° + 180° für korrekte Ausrichtung)
const finalAngle = rotationAngle + Math.PI / 2 + Math.PI;
this.ctx.rotate(finalAngle);
if (this.carImage) {
console.log(`[🚗 DRAW] Drawing car ${index} with image at x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}`);
// Zeichne Auto-Bild zentriert um den Transformationspunkt
this.ctx.drawImage(
this.carImage,
-car.width/2,
-car.height/2,
car.width,
car.height
);
} else {
console.log(`[🚗 DRAW] Drawing car ${index} with fallback rectangle at x=${car.x.toFixed(2)}, y=${car.y.toFixed(2)}`);
// Fallback: Zeichne farbiges Rechteck wenn Bild nicht geladen
this.ctx.fillStyle = car.color;
this.ctx.fillRect(-car.width/2, -car.height/2, car.width, car.height);
// Zeichne schwarze Umrandung
this.ctx.strokeStyle = '#000000';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(-car.width/2, -car.height/2, car.width, car.height);
}
this.ctx.restore();
// Debug: permanenten Crash-Bereich (Hitbox) der Autos anzeigen
const obb = this._buildObb(car, 2);
this._drawDebugPoly(obb.corners, 'rgba(255,165,0,0.15)', '#ffa500');
});
},
_drawDebugPoly(corners, fill, stroke) {
if (!corners || corners.length < 3) return;
const ctx = this.ctx;
ctx.save();
ctx.beginPath();
ctx.moveTo(corners[0].x, corners[0].y);
for (let i = 1; i < corners.length; i++) ctx.lineTo(corners[i].x, corners[i].y);
ctx.closePath();
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }
ctx.restore();
},
drawRoads() {
const tileSize = this.tiles.size; // 400x400px
// Zeichne nur das aktuelle Tile
if (this.currentMap && this.currentMap.mapData) {
const mapData = this.currentMap.mapData;
const rows = mapData.length;
const cols = mapData[0] ? mapData[0].length : 0;
// Prüfe ob aktuelle Tile-Position gültig ist
if (this.currentTile.row >= 0 && this.currentTile.row < rows &&
this.currentTile.col >= 0 && this.currentTile.col < cols) {
const tileType = mapData[this.currentTile.row][this.currentTile.col];
if (tileType) {
// Konvertiere Tile-Typ zu streetCoordinates.json Format
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
// Zeichne Straßenregionen für das aktuelle Tile
streetCoordinates.drawDriveableRegions(this.ctx, streetTileType, tileSize, 0, 0);
// Zeichne Tile-Overlay falls verfügbar
if (this.tiles.images[tileType]) {
this.ctx.drawImage(this.tiles.images[tileType], 0, 0, tileSize, tileSize);
}
// Häuser zeichnen (aus tileHouses)
const absCol = this.currentTile.col + (this.currentMap.offsetX || 0);
const absRow = this.currentTile.row + (this.currentMap.offsetY || 0);
// Haltelinien (falls Ampel vorhanden) 120px vom Rand, 5px dick, 40px breit
if (this.getTrafficLightFor(absCol, absRow)) {
const approaches = this.getTileApproaches(tileType);
this.drawStopLinesOnMainCanvas(this.ctx, tileSize, approaches);
}
this.drawHousesOnMainCanvas(this.ctx, tileSize, absCol, absRow);
// Straßennamen auf Haupt-Canvas zeichnen
// Ampeln an jeder Ecke des Tiles (über den Häusern, unter den Namen)
if (this.getTrafficLightFor(absCol, absRow)) {
const phase = this.getTrafficLightPhase(absCol, absRow); // 0,1,2,3
// Phase-Mapping je Ecke laut Vorgabe:
// 0: H grün / V rot
// 1: H gelb / V rot-gelb
// 2: H rot / V grün
// 3: H rot-gelb / V gelb
const imgGreen = this.tiles.images['trafficlight-green'];
const imgYellow = this.tiles.images['trafficlight-yellow'];
const imgRed = this.tiles.images['trafficlight-red'];
const imgRedYellow = this.tiles.images['trafficlight-redyellow'];
{
const sTL = tileSize * 0.16; // um 20% kleiner als zuvor
// Positionierung nach Vorgabe:
// X: 180px vom linken bzw. rechten Rand, dann je 60px zum näheren Rand und 5px zurück
let leftX = 180;
let rightX = tileSize - 180 - sTL;
leftX = Math.max(0, leftX - 60);
rightX = Math.min(tileSize - sTL, rightX + 60);
leftX = Math.min(tileSize - sTL, Math.max(0, leftX + 5));
rightX = Math.min(tileSize - sTL, Math.max(0, rightX - 5));
// Y: oben korrekt, unten 60px weiter nach oben
const topY = 180 - sTL;
const bottomY = tileSize - 180 - 60;
// Sichtbarkeit pro Seite bestimmen und auf Ecken mappen
const ap = this.getTileApproaches(tileType);
let showTL = false, showTR = false, showBL = false, showBR = false;
if (ap.top) { showTL = true; showTR = true; }
if (ap.bottom) { showBL = true; showBR = true; }
if (ap.left) { showTL = true; showBL = true; }
if (ap.right) { showTR = true; showBR = true; }
// Tile-spezifische Ausnahmen (z.B. T-Kreuzungen)
const hide = this.getCornerHidesForTile(tileType);
if (hide.TL) showTL = false;
if (hide.TR) showTR = false;
if (hide.BL) showBL = false;
if (hide.BR) showBR = false;
const drawByAxis = (axis, x, y) => {
// axis: 'H' (horizontal) oder 'V' (vertikal)
let img = imgRed;
if (axis === 'H') {
// Horizontal: Phase 0=grün, 1=gelb, 2=rot, 3=rot-gelb
if (phase === 0) img = imgGreen; else if (phase === 1) img = imgYellow; else if (phase === 2) img = imgRed; else img = imgRedYellow;
} else {
// Vertikal: Gegenphase: 0=rot, 1=rot-gelb, 2=grün, 3=gelb
if (phase === 0) img = imgRed; else if (phase === 1) img = imgRedYellow; else if (phase === 2) img = imgGreen; else img = imgYellow;
}
if (img && img.complete) this.ctx.drawImage(img, x, y, sTL, sTL);
};
// Diagonale Synchronisation: TL/BR vertikal, TR/BL horizontal
if (showTL) drawByAxis('V', leftX, topY);
if (showTR) drawByAxis('H', rightX, topY);
if (showBL) drawByAxis('H', leftX, bottomY);
if (showBR) drawByAxis('V', rightX, bottomY);
}
}
this.drawStreetNamesOnMainCanvas(this.ctx, tileSize, tileType, absCol, absRow);
}
}
}
else {
// Fallback: Zeichne Standard-Tile wenn keine Map geladen ist
const tileType = 'cornertopleft'; // Standard-Tile
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
// Zeichne Straßenregionen
streetCoordinates.drawDriveableRegions(this.ctx, streetTileType, tileSize, 0, 0);
// Zeichne Tile-Overlay falls verfügbar
if (this.tiles.images[tileType]) {
this.ctx.drawImage(this.tiles.images[tileType], 0, 0, tileSize, tileSize);
}
}
},
getTrafficLightFor(col, row) {
// Prüfe Tiles-Array, ob trafficLight-Spalte oder meta.trafficLight gesetzt ist
if (!this.currentMap || !Array.isArray(this.currentMap.tiles)) return false;
const found = this.currentMap.tiles.find(t => t.x === col && t.y === row && (t.trafficLight === true || (t.meta && t.meta.trafficLight)));
return !!found;
},
getTrafficLightPhase(col, row) {
const key = `${col},${row}`;
const st = this.trafficLightStates[key];
if (!st) return 0; // default
return st.phase || 0;
},
drawStreetNamesOnMainCanvas(ctx, size, tileType, col, row) {
if (!this.currentMap || !Array.isArray(this.currentMap.tileStreets)) return;
const entry = this.currentMap.tileStreets.find(ts => ts.x === col && ts.y === row);
if (!entry) return;
const fontPx = 12;
const drawText = (text, px, py, rot = 0, selected = false) => {
ctx.save();
ctx.translate(px, py);
if (rot) ctx.rotate(rot);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `bold ${fontPx}px sans-serif`;
ctx.lineWidth = Math.max(2, Math.floor(fontPx / 6));
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.strokeText(text, 0, 0);
ctx.fillStyle = selected ? '#ff4d4d' : '#222';
ctx.fillText(text, 0, 0);
ctx.restore();
};
const hName = entry.streetNameH?.name || null;
const vName = entry.streetNameV?.name || null;
if (vName) {
const sel = this.selectedStreetName && this.selectedStreetName === vName;
const cx = size / 2;
drawText(vName, cx, fontPx +1, 0, sel);
drawText(vName, cx, size - fontPx -1, 0, sel);
}
if (hName) {
const sel = this.selectedStreetName && this.selectedStreetName === hName;
const cy = size / 2;
drawText(hName, fontPx, cy, -Math.PI / 2, sel);
drawText(hName, size - fontPx, cy, -Math.PI / 2, sel);
}
},
drawHousesOnMainCanvas(ctx, size, col, row) {
if (!this.currentMap) return;
const houses = Array.isArray(this.currentMap.tileHouses) ? this.currentMap.tileHouses : [];
if (!houses.length || !this.houseImage) return;
// Finde alle Häuser für diese Zelle
const list = houses.filter(h => h.x === col && h.y === row);
if (!list.length) return;
// Hausgröße 150x150px; an Ecken platzieren mit Abstand 3px
const HOUSE_W = 150;
const HOUSE_H = 150;
const pad = 30;
for (const h of list) {
const rot = (Number(h.rotation) || 0) % 360;
const isPortrait = (rot % 180) !== 0; // 90 oder 270 Grad
const w = isPortrait ? HOUSE_H : HOUSE_W;
const hgt = isPortrait ? HOUSE_W : HOUSE_H;
let px = 0, py = 0;
switch (h.corner) {
case 'lo': px = pad; py = pad; break;
case 'ro': px = size - w - pad; py = pad; break;
case 'lu': px = pad; py = size - hgt - pad; break;
case 'ru': px = size - w - pad; py = size - hgt - pad; break;
default: continue;
}
// Rotation: Bild zeigt Tür unten (0°). 90/180/270 drehen um Zentrum.
const cx = px + w / 2;
const cy = py + hgt / 2;
ctx.save();
ctx.translate(cx, cy);
const rad = rot * Math.PI / 180;
ctx.rotate(rad);
ctx.drawImage(this.houseImage, -HOUSE_W/2, -HOUSE_H/2, HOUSE_W, HOUSE_H);
// Hausnummer zeichnen (über dem Bild zentriert)
const key = `${h.x},${h.y},${h.corner}`;
const num = this.houseNumbers[key];
if (num != null) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 20px sans-serif';
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.strokeText(String(num), 0, 0);
ctx.fillStyle = '#000';
ctx.fillText(String(num), 0, 0);
}
// Passagier zeichnen (falls vorhanden)
this.drawPassengerOnHouse(ctx, h, col, row);
ctx.restore();
}
},
drawPassengerOnHouse(ctx, house, col, row) {
// Finde Passagier für dieses Haus
const passenger = this.waitingPassengersList.find(p =>
p.tileX === col && p.tileY === row && (p.houseIndex === house.corner || p.houseCorner === house.corner)
);
if (!passenger || !this.passengerImages[passenger.imageIndex]) {
return; // Kein Passagier oder Bild nicht geladen
}
const passengerImg = this.passengerImages[passenger.imageIndex];
const PASSENGER_SIZE = 40; // Größe des Passagiers
const HOUSE_H = 150; // Haushöhe (muss mit drawHousesOnMainCanvas übereinstimmen)
// Zeichne Passagier auf dem Boden vor dem Haus (stehend)
// Der untere Rand der Figur soll mit dem unteren Rand des Hauses ausgerichtet sein
ctx.drawImage(
passengerImg,
-PASSENGER_SIZE/2,
HOUSE_H/2 - PASSENGER_SIZE, // Unterer Rand der Figur am unteren Rand des Hauses
PASSENGER_SIZE,
PASSENGER_SIZE
);
},
// Haltelinien: Basierend auf den korrigierten Koordinaten
drawStopLinesOnMainCanvas(ctx, size, approaches = { top:true, right:true, bottom:true, left:true }) {
const thickness = 5;
ctx.save();
ctx.fillStyle = '#ffffff';
// Basierend auf den korrigierten Koordinaten:
// Links: sichtbar: 145/294 - 150/313
if (approaches.left) ctx.fillRect(145, 294, thickness, 19);
// Rechts: sichtbar: 150/186 - 355/293
if (approaches.right) ctx.fillRect(350, 186, thickness, 107);
// Unten: sichtbar: 250/360 - 312/365
if (approaches.bottom) ctx.fillRect(250, 360, 62, thickness);
// Oben: berechnet aus den anderen Angaben (symmetrisch zu unten)
if (approaches.top) ctx.fillRect(250, 5, 62, thickness);
ctx.restore();
},
// Liefert die virtuellen (breit verdoppelten) Haltelinienrechtecke pro Seite, ohne zu zeichnen
getVirtualStopLineRects(size, approaches = { top:true, right:true, bottom:true, left:true }) {
const thickness = 5 * 2; // doppelte Breite für Erkennung
const rects = {};
// Basierend auf den korrigierten Koordinaten:
// Links: virtuell: 145/186 - 150/313; sichtbar: 145/294 - 150/313
if (approaches.left) rects.left = { x: 145, y: 186, width: thickness, height: 127 };
// Rechts: virtuell: 350/186 - 355/313; sichtbar: 150/186 - 355/293
if (approaches.right) rects.right = { x: 350, y: 186, width: thickness, height: 127 };
// Unten: virtuell: 187/360- 312/365; sichtbar: 250/360 - 312/365
if (approaches.bottom) rects.bottom = { x: 187, y: 360, width: 125, height: thickness };
// Oben: berechnet aus den anderen Angaben (symmetrisch zu unten)
if (approaches.top) rects.top = { x: 187, y: 5, width: 125, height: thickness };
return rects;
},
// Liefert, von welchen Seiten eine Straße an dieses Tile anbindet
getTileApproaches(tileType) {
switch (tileType) {
case 'horizontal': return { top:false, right:true, bottom:false, left:true };
case 'vertical': return { top:true, right:false, bottom:true, left:false };
case 'cross': return { top:true, right:true, bottom:true, left:true };
case 'cornertopleft': return { top:true, right:false, bottom:false, left:true };
case 'cornertopright': return { top:true, right:true, bottom:false, left:false };
case 'cornerbottomleft': return { top:false, right:false, bottom:true, left:true };
case 'cornerbottomright': return { top:false, right:true, bottom:true, left:false };
case 'tup': return { top:true, right:true, bottom:false, left:true }; // T-oben: unten gesperrt
case 'tdown': return { top:false, right:true, bottom:true, left:true }; // T-unten: oben gesperrt
case 'tleft': return { top:true, right:false, bottom:true, left:true }; // T-links: rechts gesperrt
case 'tright': return { top:true, right:true, bottom:true, left:false }; // T-rechts: links gesperrt
case 'fuelhorizontal': return { top:false, right:true, bottom:false, left:true };
case 'fuelvertical': return { top:true, right:false, bottom:true, left:false };
default: return { top:true, right:true, bottom:true, left:true };
}
},
// Ecken-spezifische Ausblendungen je Tiletyp (zusätzlich zu Seiten-Logik)
getCornerHidesForTile(tileType) {
// TL=oben links, TR=oben rechts, BL=unten links, BR=unten rechts
switch (tileType) {
case 'tdown': return { TL: true, TR: false, BL: false, BR: false }; // oben gesperrt -> oben links weg
case 'tup': return { TL: false, TR: false, BL: false, BR: true }; // unten gesperrt -> unten rechts weg
case 'tleft': return { TL: false, TR: true, BL: false, BR: false }; // rechts gesperrt -> oben rechts weg
case 'tright': return { TL: false, TR: false, BL: true, BR: false }; // links gesperrt -> unten links weg
default: return { TL: false, TR: false, BL: false, BR: false };
}
},
getTileType(row, col, rows, cols) {
// Ecken
if (row === 0 && col === 0) return 'cornertopleft';
if (row === 0 && col === cols - 1) return 'cornertopright';
if (row === rows - 1 && col === 0) return 'cornerbottomleft';
if (row === rows - 1 && col === cols - 1) return 'cornerbottomright';
// Ränder
if (row === 0 || row === rows - 1) return 'horizontal';
if (col === 0 || col === cols - 1) return 'vertical';
// Innere Bereiche - zufällige Straßen
const rand = Math.random();
if (rand < 0.3) return 'cross';
if (rand < 0.5) return 'horizontal';
if (rand < 0.7) return 'vertical';
if (rand < 0.8) return 'fuelhorizontal';
if (rand < 0.9) return 'fuelvertical';
return 'horizontal'; // Fallback
},
async handleCanvasClick(event) {
// AudioContext bei erster Benutzerinteraktion initialisieren
this.ensureAudioUnlockedInEvent();
// Motor ggf. direkt starten (Klick ist User-Geste)
if (this.motorSound && !this.motorSound.isPlaying) {
this.motorSound.start();
}
},
async handleKeyDown(event) {
// Browser-Shortcuts (F-Tasten, Strg/Meta+R) passieren lassen
const key = event.key;
const isFunctionKey = /^F\d{1,2}$/.test(key);
const isReloadShortcut = (event.ctrlKey || event.metaKey) && (key === 'r' || key === 'R');
if (isFunctionKey || isReloadShortcut) {
return; // nicht abfangen, Browser soll handeln
}
this.keys[key] = true;
// AudioContext bei erster Benutzerinteraktion initialisieren
this.ensureAudioUnlockedInEvent();
// Motor nur starten wenn Spiel nicht pausiert ist
if (!this.isPaused && (event.key === 'ArrowUp' || event.key === 'w' || event.key === 'W') && this.motorSound && !this.motorSound.isPlaying) {
this.motorSound.start();
// Direkt Parameter setzen, um hörbares Feedback ohne Verzögerung zu bekommen
const speedKmh = Math.max(5, this.taxi.speed * 5);
const speedFactor = Math.min(speedKmh / 120, 1);
const motorSpeed = 0.3 + (speedFactor * 0.3);
const volume = 0.1 + (speedFactor * 0.4);
this.motorSound.setSpeed(motorSpeed);
this.motorSound.setVolume(volume);
}
event.preventDefault();
},
ensureAudioUnlockedInEvent() {
// Nur einmal initialisieren
if (this.audioContext && this.motorSound) {
return; // Bereits initialisiert
}
// Muss synchron im Event-Handler laufen (ohne await), damit Policies greifen
if (!this.audioContext) {
this.audioContext = window.__TaxiAudioContext || new (window.AudioContext || window.webkitAudioContext)();
window.__TaxiAudioContext = this.audioContext;
}
try {
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
} catch (e) {
// ignore
}
if (!this.motorSound) {
this.motorSound = window.__TaxiMotorSound;
if (!this.motorSound) {
const generator = new NoiseGenerator();
this.motorSound = new MotorSound(this.audioContext, generator);
// nicht awaiten, bleibt im gleichen Event-Frame
this.motorSound.init();
window.__TaxiMotorSound = this.motorSound;
}
}
},
handleKeyUp(event) {
const key = event.key;
const isFunctionKey = /^F\d{1,2}$/.test(key);
const isReloadShortcut = (event.ctrlKey || event.metaKey) && (key === 'r' || key === 'R');
if (isFunctionKey || isReloadShortcut) {
return; // Browser-Shortcut nichts am Spielzustand ändern
}
this.keys[key] = false;
},
togglePause() {
this.isPaused = !this.isPaused;
this.showPauseOverlay = this.isPaused;
// Motorgeräusch stoppen wenn pausiert, starten wenn fortgesetzt
if (this.isPaused) {
if (this.motorSound && this.motorSound.isPlaying) {
this.motorSound.stop();
}
}
// Wenn fortgesetzt wird, startet der Motor automatisch bei der nächsten Beschleunigung
},
restartLevel() {
this.score = 0;
this.money = 0;
this.passengersDelivered = 0;
this.waitingPassengersList = [];
this.loadedPassengersList = [];
this.occupiedHouses.clear();
this.fuel = 100;
this.taxi.x = 250;
this.taxi.y = 250;
this.taxi.angle = 0;
this.taxi.speed = 0;
// Reset Spielzeit
this.gameStartTime = null;
// Cleanup bestehender Timeouts
if (this.passengerGenerationTimeout) {
clearTimeout(this.passengerGenerationTimeout);
this.passengerGenerationTimeout = null;
}
if (this.crashDialogTimeout) {
clearTimeout(this.crashDialogTimeout);
this.crashDialogTimeout = null;
}
this.generateLevel();
this.initializePassengerGeneration();
},
initializeMinimap() {
this.minimapCanvas = this.$refs.minimapCanvas;
this.minimapCtx = this.minimapCanvas.getContext('2d');
},
async loadTiles() {
const tileNames = [
'cornerbottomleft', 'cornerbottomright', 'cornertopleft', 'cornertopright',
'cross', 'fuelhorizontal', 'fuelvertical', 'horizontal', 'vertical',
'tleft', 'tright', 'tup', 'tdown'
];
const mapTileNames = [
'map-cornerbottomleft', 'map-cornerbottomright', 'map-cornertopleft', 'map-cornertopright',
'map-cross', 'map-fuelhorizontal', 'map-fuelvertical', 'map-horizontal', 'map-vertical',
'map-tleft', 'map-tright', 'map-tup', 'map-tdown'
];
// Lade normale Tiles
for (const tileName of tileNames) {
const img = new Image();
img.src = `/images/taxi/${tileName}.svg`;
this.tiles.images[tileName] = img;
}
// Lade Map-Tiles
for (const tileName of mapTileNames) {
const img = new Image();
img.src = `/images/taxi/${tileName}.svg`;
this.tiles.images[tileName] = img;
}
// Lade Ampel-Icon (zentral, 50% Größe)
const red = new Image();
red.src = '/images/taxi/redlight.svg';
this.tiles.images['redlight-svg'] = red;
// Lade Trafficlight-Zustände (für großes Tile an den Ecken)
const tlStates = ['trafficlight-red','trafficlight-yellow','trafficlight-redyellow','trafficlight-green'];
for (const key of tlStates) {
const img = new Image();
img.src = `/images/taxi/${key}.svg`;
this.tiles.images[key] = img;
}
// Radar-Icon
const radar = new Image();
radar.src = '/images/taxi/radar.svg';
this.tiles.images['radar'] = radar;
},
loadTaxiImage() {
const img = new Image();
img.onload = () => {
// console.log('Taxi-Bild erfolgreich geladen:', img);
this.taxi.image = img;
};
img.onerror = (error) => {
console.error('Fehler beim Laden des Taxi-Bildes:', error);
console.log('Versuche Pfad:', '/images/taxi/taxi.svg');
};
img.src = '/images/taxi/taxi.svg';
// console.log('Lade Taxi-Bild von:', '/images/taxi/taxi.svg');
},
loadHouseImage() {
const img = new Image();
img.onload = () => {
this.houseImage = img;
};
img.onerror = () => {
console.warn('Fehler beim Laden von house_small.png');
};
img.src = '/images/taxi/house_small.png';
},
loadPassengerImages() {
// Lade alle 6 Passagier-Bilder (1-3 männlich, 4-6 weiblich)
for (let i = 1; i <= 6; i++) {
const img = new Image();
img.onload = () => {
this.passengerImages[i] = img;
};
img.onerror = () => {
console.warn(`Fehler beim Laden von passenger${i}.png`);
};
img.src = `/images/taxi/passenger${i}.png`;
}
},
loadCarImage() {
const img = new Image();
img.onload = () => {
this.carImage = img;
};
img.onerror = () => {
console.warn('Fehler beim Laden von car1.svg');
};
img.src = '/images/taxi/car1.svg';
},
async loadMaps() {
try {
const response = await apiClient.get('/api/taxi-maps/maps');
this.maps = (response.data.data || []).map(m => this.buildMapDataFromTiles(m));
// Verwende die erste verfügbare Map als Standard
if (this.maps.length > 0) {
this.currentMap = this.maps[0];
this.selectedMapId = this.maps[0].id;
// Canvas-Größe an geladene Map anpassen
this.updateCanvasSize();
// Minimap neu zeichnen nach dem Laden der Map
this.$nextTick(() => {
this.computeHouseNumbers();
this.drawMinimap();
});
}
} catch (error) {
console.error('Fehler beim Laden der Maps:', error);
}
},
onMapChange() {
// Wechsle zur ausgewählten Map
const selectedMap = this.maps.find(map => map.id === this.selectedMapId);
if (selectedMap) {
this.currentMap = this.buildMapDataFromTiles({ ...selectedMap });
// console.log('Gewechselt zu Map:', selectedMap);
// Canvas-Größe an neue Map anpassen
this.updateCanvasSize();
// Taxi in die Mitte des 400x400px Tiles positionieren
this.taxi.x = 250 - this.taxi.width/2; // Zentriert in der Mitte
this.taxi.y = 250 - this.taxi.height/2; // Zentriert in der Mitte
this.taxi.angle = 0; // Fahrtrichtung nach oben
this.taxi.speed = 0; // Geschwindigkeit zurücksetzen
// Aktuelle Tile-Position zurücksetzen
this.currentTile.row = 0;
this.currentTile.col = 0;
// Minimap neu zeichnen
this.$nextTick(() => {
this.computeHouseNumbers();
this.drawMinimap();
});
}
},
drawMinimap() {
if (!this.minimapCtx) return;
const ctx = this.minimapCtx;
const canvas = this.minimapCanvas;
// Minimap löschen
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Hintergrund
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Zeichne Map-Tiles aus der geladenen Map
if (this.currentMap && this.currentMap.mapData) {
const mapData = this.currentMap.mapData;
const rows = mapData.length;
const cols = mapData[0] ? mapData[0].length : 0;
// Berechne optimale Tile-Größe für quadratische Tiles
const maxTiles = Math.max(rows, cols);
const tileSize = Math.min(canvas.width / maxTiles, canvas.height / maxTiles);
// Zentriere die Map im Canvas
const offsetX = (canvas.width - cols * tileSize) / 2;
const offsetY = (canvas.height - rows * tileSize) / 2;
// Set, um pro Straßennamen nur eine Nummer zu zeichnen
const drawnNames = new Set();
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = offsetX + col * tileSize;
const y = offsetY + row * tileSize;
const tileType = mapData[row][col];
if (tileType) {
const mapTileType = 'map-' + tileType;
// Zeichne Map-Tile falls verfügbar
if (this.tiles.images[mapTileType]) {
ctx.drawImage(this.tiles.images[mapTileType], x, y, tileSize, tileSize);
}
// Straßensegment der ausgewählten Straße hervorheben
const absCol = col + (this.currentMap.offsetX || 0);
const absRow = row + (this.currentMap.offsetY || 0);
this.drawStreetFillOnMinimap(ctx, x, y, tileSize, tileType, absCol, absRow);
// Straßennamen-Nummern zeichnen, basierend auf tileStreets (nur einmal pro Name)
this.drawStreetNumbersOnMinimap(ctx, x, y, tileSize, tileType, absCol, absRow, drawnNames);
// Ampel im Minimap zentriert (50% Größe)
if (this.getTrafficLightFor(absCol, absRow)) {
const img = this.tiles.images['redlight-svg'];
if (img && img.complete) {
const s = tileSize * 0.3; // 60% der bisherigen 50%-Größe
ctx.drawImage(img, x + (tileSize - s) / 2, y + (tileSize - s) / 2, s, s);
}
}
}
}
}
} else {
// Fallback: Zeichne statische Map wenn keine Map geladen ist
const cols = 8;
const rows = 8;
// Berechne optimale Tile-Größe für quadratische Tiles
const maxTiles = Math.max(rows, cols);
const tileSize = Math.min(canvas.width / maxTiles, canvas.height / maxTiles);
// Zentriere die Map im Canvas
const offsetX = (canvas.width - cols * tileSize) / 2;
const offsetY = (canvas.height - rows * tileSize) / 2;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = offsetX + col * tileSize;
const y = offsetY + row * tileSize;
let tileType = this.getTileType(row, col, rows, cols);
let mapTileType = 'map-' + tileType;
// Zeichne Map-Tile falls verfügbar
if (this.tiles.images[mapTileType]) {
ctx.drawImage(this.tiles.images[mapTileType], x, y, tileSize, tileSize);
}
// Keine echten Namen im Fallback
}
}
}
// Berechne Skalierung für Passagiere, Ziele und Tankstellen
let scaleX, scaleY, offsetX, offsetY;
if (this.currentMap && this.currentMap.mapData) {
const mapData = this.currentMap.mapData;
const rows = mapData.length;
const cols = mapData[0] ? mapData[0].length : 0;
const maxTiles = Math.max(rows, cols);
const tileSize = Math.min(canvas.width / maxTiles, canvas.height / maxTiles);
scaleX = tileSize / 500; // 400 ist die neue Tile-Größe
scaleY = tileSize / 500;
offsetX = (canvas.width - cols * tileSize) / 2;
offsetY = (canvas.height - rows * tileSize) / 2;
} else {
// Fallback für statische Map
const maxTiles = 8;
const tileSize = Math.min(canvas.width / maxTiles, canvas.height / maxTiles);
scaleX = tileSize / 500;
scaleY = tileSize / 500;
offsetX = (canvas.width - 8 * tileSize) / 2;
offsetY = (canvas.height - 8 * tileSize) / 2;
}
// Taxi (blau) - Position basierend auf aktueller Tile-Position
ctx.fillStyle = '#2196F3';
ctx.beginPath();
const taxiGlobalX = this.currentTile.col * 500 + this.taxi.x;
const taxiGlobalY = this.currentTile.row * 500 + this.taxi.y;
ctx.arc(offsetX + taxiGlobalX * scaleX, offsetY + taxiGlobalY * scaleY, 3, 0, 2 * Math.PI);
ctx.fill();
// Taxi-Richtung anzeigen
ctx.strokeStyle = '#2196F3';
ctx.lineWidth = 1;
ctx.beginPath();
const taxiX = offsetX + taxiGlobalX * scaleX;
const taxiY = offsetY + taxiGlobalY * scaleY;
const endX = taxiX + Math.cos(this.taxi.angle) * 6;
const endY = taxiY + Math.sin(this.taxi.angle) * 6;
ctx.moveTo(taxiX, taxiY);
ctx.lineTo(endX, endY);
ctx.stroke();
},
buildMapDataFromTiles(map) {
if (!map || !Array.isArray(map.tiles) || map.tiles.length === 0) {
map.mapData = null;
map.offsetX = 0;
map.offsetY = 0;
return map;
}
const xs = map.tiles.map(t => t.x);
const ys = map.tiles.map(t => t.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const width = (maxX - minX + 1) || 1;
const height = (maxY - minY + 1) || 1;
const grid = Array.from({ length: height }, () => Array(width).fill(null));
for (const t of map.tiles) {
const col = t.x - minX;
const row = t.y - minY;
if (row >= 0 && row < height && col >= 0 && col < width) {
grid[row][col] = t.tileType;
}
}
map.mapData = grid;
map.offsetX = minX;
map.offsetY = minY;
// tileHouses vom Backend übernehmen (Array von Zeilen)
map.tileHouses = Array.isArray(map.tileHouses) ? map.tileHouses : [];
return map;
},
onSelectStreet(name) {
this.selectedStreetName = (this.selectedStreetName === name) ? null : name;
},
// Variante B: gesamte Straßenfläche im Tile halbtransparent rot füllen
drawStreetFillOnMinimap(ctx, x, y, size, tileType, col, row) {
if (!this.selectedStreetName || !this.currentMap || !Array.isArray(this.currentMap.tileStreets)) return;
const entry = this.currentMap.tileStreets.find(ts => ts.x === col && ts.y === row);
if (!entry) return;
const wantH = entry.streetNameH?.name === this.selectedStreetName;
const wantV = entry.streetNameV?.name === this.selectedStreetName;
if (!wantH && !wantV) return;
const streetTileType = this.mapTileTypeToStreetCoordinates(tileType);
const regions = streetCoordinates.getDriveableRegions(streetTileType);
if (!regions || regions.length === 0) return;
// Banddicke exakt 12px (bei Referenz 50px) skaliert auf aktuelle size
const scale = size / 50;
const bandThickness = 12 * scale; // px
// Clip: Tile-Rechteck MINUS Hindernis-Regionen => übrig bleibt die graue Straßenfläche
ctx.save();
ctx.beginPath();
// 1) volles Tile
ctx.rect(x, y, size, size);
// 2) Hindernis-Regionen hinzufügen (EvenOdd)
for (const region of regions) {
if (!region || region.length === 0) continue;
ctx.moveTo(x + region[0].x, y + region[0].y);
for (let i = 1; i < region.length; i++) {
ctx.lineTo(x + region[i].x, y + region[i].y);
}
ctx.closePath();
}
// EvenOdd erhält Fläche außerhalb der Regionen (Straße)
ctx.clip('evenodd');
ctx.fillStyle = '#ff4d4d';
const marginRight = 1; // rechts 1px kürzen
const marginBottom = 1; // unten 1px kürzen
if (wantH) {
const by = y + size * 0.5 - bandThickness / 2;
ctx.fillRect(x, by, size, bandThickness);
}
if (wantV) {
const bx = x + size * 0.5 - bandThickness / 2;
ctx.fillRect(bx, y, bandThickness, size);
}
ctx.restore();
},
// ---- Hausnummern-Berechnung ----
computeHouseNumbers() {
this.houseNumbers = {};
if (!this.currentMap || !this.currentMap.tileHouses || !this.currentMap.mapData) return;
const map = this.currentMap;
const offsetX = map.offsetX || 0;
const offsetY = map.offsetY || 0;
const streetIndex = new Map();
const getEntry = (name) => { if (!streetIndex.has(name)) streetIndex.set(name, { tiles: new Map() }); return streetIndex.get(name); };
const tileStreets = Array.isArray(map.tileStreets) ? map.tileStreets : [];
for (const ts of tileStreets) {
const x = ts.x; const y = ts.y; const gridX = x - offsetX; const gridY = y - offsetY;
const tileType = (map.mapData[gridY] && map.mapData[gridY][gridX]) || null; if (!tileType) continue;
const makeTileObj = (name) => { const key = `${x},${y}`; const e = getEntry(name); const t = e.tiles.get(key) || { x, y, tileType, h: false, v: false }; e.tiles.set(key, t); return t; };
if (ts.streetNameH && ts.streetNameH.name) { makeTileObj(ts.streetNameH.name).h = true; }
if (ts.streetNameV && ts.streetNameV.name) { makeTileObj(ts.streetNameV.name).v = true; }
}
const allowedDirections = (tileType) => {
switch (tileType) {
case 'cornertopleft': return { left: true, up: true, right: false, down: false };
case 'cornertopright': return { right: true, up: true, left: false, down: false };
case 'cornerbottomleft': return { left: true, down: true, right: false, up: false };
case 'cornerbottomright': return { right: true, down: true, left: false, up: false };
case 'horizontal':
case 'fuelhorizontal': return { left: true, right: true, up: false, down: false };
case 'vertical':
case 'fuelvertical': return { up: true, down: true, left: false, right: false };
case 'cross': return { left: true, right: true, up: true, down: true };
case 'tup': return { up: true, left: true, right: true, down: false };
case 'tdown': return { down: true, left: true, right: true, up: false };
case 'tleft': return { left: true, up: true, down: true, right: false };
case 'tright': return { right: true, up: true, down: true, left: false };
default: return { left: false, right: false, up: false, down: false };
}
};
const getTile = (entry, x, y) => entry.tiles.get(`${x},${y}`);
for (const [name, entry] of streetIndex.entries()) {
const nodes = new Map();
const addNode = (x, y, axis) => nodes.set(`${x},${y},${axis}`, { x, y, axis });
for (const t of entry.tiles.values()) { if (t.h) addNode(t.x, t.y, 'h'); if (t.v) addNode(t.x, t.y, 'v'); }
const adj = new Map();
const addEdge = (a, b) => { if (!nodes.has(a) || !nodes.has(b)) return; if (!adj.has(a)) adj.set(a, new Set()); if (!adj.has(b)) adj.set(b, new Set()); adj.get(a).add(b); adj.get(b).add(a); };
for (const t of entry.tiles.values()) {
const dirs = allowedDirections(t.tileType); const keyH = `${t.x},${t.y},h`; const keyV = `${t.x},${t.y},v`;
if (t.h) { if (dirs.left) { const n = getTile(entry, t.x - 1, t.y); if (n && n.h && allowedDirections(n.tileType).right) addEdge(keyH, `${n.x},${n.y},h`); } if (dirs.right) { const n = getTile(entry, t.x + 1, t.y); if (n && n.h && allowedDirections(n.tileType).left) addEdge(keyH, `${n.x},${n.y},h`); } }
if (t.v) { if (dirs.up) { const n = getTile(entry, t.x, t.y - 1); if (n && n.v && allowedDirections(n.tileType).down) addEdge(keyV, `${n.x},${n.y},v`); } if (dirs.down) { const n = getTile(entry, t.x, t.y + 1); if (n && n.v && allowedDirections(n.tileType).up) addEdge(keyV, `${n.x},${n.y},v`); } }
if (t.h && t.v) { addEdge(keyH, keyV); }
}
if (nodes.size === 0) continue;
// Startknoten deterministisch wählen:
// 1) bevorzuge Endpunkte (Grad=1)
// 2) bevorzuge horizontale Segmente (axis==='h') und wähle den mit kleinstem x (links)
// 3) sonst vertikale Segmente (axis==='v') mit kleinstem y (oben)
const nodeEntries = Array.from(nodes.entries());
const deg = (k) => (adj.get(k)?.size || 0);
let candidates = nodeEntries.filter(([k]) => deg(k) === 1);
if (candidates.length === 0) candidates = nodeEntries;
let horiz = candidates.filter(([, n]) => n.axis === 'h');
let startPair = null;
if (horiz.length > 0) {
// Links-vorne nimmt Vorrang (kleinstes x, dann kleinstes y)
startPair = horiz.reduce((best, curr) => {
if (!best) return curr;
const bn = best[1], cn = curr[1];
if (cn.x < bn.x) return curr;
if (cn.x === bn.x && cn.y < bn.y) return curr;
return best;
}, null);
} else {
const vert = candidates.filter(([, n]) => n.axis === 'v');
startPair = vert.reduce((best, curr) => {
if (!best) return curr;
const bn = best[1], cn = curr[1];
if (cn.y < bn.y) return curr;
if (cn.y === bn.y && cn.x < bn.x) return curr;
return best;
}, null) || candidates[0];
}
let startKey = startPair ? startPair[0] : (nodeEntries[0] && nodeEntries[0][0]);
const visited = new Set(); let odd = 1, even = 2; let prev = null; let curr = startKey; let currOddSide = (nodes.get(curr).axis === 'h') ? 'top' : 'left';
while (curr) {
visited.add(curr);
const { x, y, axis } = nodes.get(curr);
const neighbors = Array.from(adj.get(curr) || []); const next = neighbors.find(n => !visited.has(n));
let dir = { dx: 0, dy: 0 }; if (next) { const nNode = nodes.get(next); dir = { dx: nNode.x - x, dy: nNode.y - y }; } else if (prev) { const pNode = nodes.get(prev); dir = { dx: x - pNode.x, dy: y - pNode.y }; }
if (prev) { const prevAxis = nodes.get(prev).axis; if (prevAxis !== axis) { if (prevAxis === 'v' && axis === 'h') currOddSide = 'bottom'; else if (prevAxis === 'h' && axis === 'v') currOddSide = 'left'; } }
const houseMap = this.currentMap.tileHouses || []; const housesHere = houseMap.filter(hh => hh.x === x && hh.y === y);
if (housesHere.length) {
const doorSideFromRotation = (rot) => {
const r = ((Number(rot) || 0) % 360 + 360) % 360;
if (r === 0) return 'bottom';
if (r === 90) return 'left';
if (r === 180) return 'top';
if (r === 270) return 'right';
return 'bottom';
};
// Nur Häuser berücksichtigen, deren Tür zur aktuellen Straßenachse passt
const housesFiltered = housesHere.filter(hh => {
const side = doorSideFromRotation(hh.rotation);
return axis === 'h' ? (side === 'top' || side === 'bottom') : (side === 'left' || side === 'right');
});
if (!housesFiltered.length) { prev = curr; curr = next || null; continue; }
const orderCorners = (list) => {
if (axis === 'h') {
// Erzwinge Nummerierung beginnend von links nach rechts
const seq = ['lo','ro','lu','ru'];
return list.slice().sort((a,b)=> seq.indexOf(a) - seq.indexOf(b));
} else {
const movingDown = dir.dy > 0 || (dir.dy === 0 && !prev);
const seq = movingDown ? ['lo','lu','ro','ru'] : ['lu','lo','ru','ro'];
return list.slice().sort((a,b)=> seq.indexOf(a) - seq.indexOf(b));
}
};
const oddCorners = (axis === 'h') ? (currOddSide === 'top' ? ['lo','ro'] : ['lu','ru']) : (currOddSide === 'left' ? ['lo','lu'] : ['ro','ru']);
const evenCorners = (axis === 'h') ? (currOddSide === 'top' ? ['lu','ru'] : ['lo','ro']) : (currOddSide === 'left' ? ['ro','ru'] : ['lo','lu']);
for (const c of orderCorners(oddCorners)) { if (housesFiltered.find(hh => hh.corner === c)) { const k = `${x},${y},${c}`; if (this.houseNumbers[k] == null) { this.houseNumbers[k] = odd; odd += 2; } } }
for (const c of orderCorners(evenCorners)) { if (housesFiltered.find(hh => hh.corner === c)) { const k = `${x},${y},${c}`; if (this.houseNumbers[k] == null) { this.houseNumbers[k] = even; even += 2; } } }
}
prev = curr; curr = next || null;
}
}
},
// Highscore-Funktionen
async toggleHighscore() {
this.showHighscore = !this.showHighscore;
if (this.showHighscore) {
// Spiel pausieren wenn Highscore angezeigt wird
if (!this.isPaused) {
this.isPaused = true;
// Game Loop stoppen
if (this.gameLoop) {
clearTimeout(this.gameLoop);
this.gameLoop = null;
}
// Motorgeräusch stoppen wenn pausiert
if (this.motorSound && this.motorSound.isPlaying) {
this.motorSound.stop();
}
}
// Highscore laden
await this.loadHighscore();
} else {
// Highscore geschlossen - Spiel automatisch fortsetzen
this.isPaused = false;
this.showPauseOverlay = false;
// Game Loop neu starten
if (this.gameRunning && !this.gameLoop) {
this.gameLoop = setTimeout(this.update, 62);
}
// Motor startet automatisch bei der nächsten Beschleunigung
}
},
async loadHighscore() {
this.loadingHighscore = true;
try {
// Lade Top 20 Highscores für die aktuelle Map
const response = await apiClient.get('/api/taxi/highscores', {
params: {
mapId: this.selectedMapId,
limit: 20,
orderBy: 'points' // Sortiere nach Punkten
}
});
if (response.data && response.data.success && Array.isArray(response.data.data)) {
this.highscoreList = response.data.data.map((entry, index) => ({
rank: index + 1,
nickname: entry.nickname || 'Unbekannt',
points: entry.points,
isCurrentPlayer: entry.userId === this.$store.state.user?.id
}));
// Prüfe ob aktueller Spieler eine Platzierung hat
await this.checkCurrentPlayerRank();
}
} catch (error) {
console.error('Fehler beim Laden der Highscores:', error);
this.highscoreList = [];
} finally {
this.loadingHighscore = false;
}
},
async checkCurrentPlayerRank() {
if (!this.$store.state.user?.id) return;
try {
// Lade Rang des aktuellen Spielers
const response = await apiClient.get('/api/taxi/highscores/rank', {
params: {
userId: this.$store.state.user.id,
mapId: this.selectedMapId,
orderBy: 'points'
}
});
if (response.data && response.data.success && response.data.data && response.data.data.rank) {
const rank = response.data.data.rank;
// Wenn Spieler Platz 21 oder schlechter hat
if (rank > 20) {
this.showCurrentPlayerBelow = true;
// Lade beste Punkte des Spielers
const bestScoreResponse = await apiClient.get('/api/taxi/highscores/user/best', {
params: {
userId: this.$store.state.user.id,
mapId: this.selectedMapId
}
});
if (bestScoreResponse.data && bestScoreResponse.data.success && bestScoreResponse.data.data) {
this.currentPlayerEntry = {
rank: rank,
nickname: this.$store.state.user.nickname || 'Du',
points: bestScoreResponse.data.data.points,
isCurrentPlayer: true
};
}
} else {
this.showCurrentPlayerBelow = false;
this.currentPlayerEntry = null;
}
}
} catch (error) {
console.error('Fehler beim Laden des Spieler-Rangs:', error);
}
}
}
}
</script>
<style scoped>
/* Verwendet globale Scroll-Klassen: .contenthidden und .contentscroll */
.game-title {
text-align: center;
margin-bottom: 30px;
padding-top: 20px;
}
.game-title h1 {
margin: 0 0 10px 0;
font-size: 2rem;
font-weight: 600;
color: #333;
}
.game-title p {
margin: 0;
font-size: 1.1rem;
color: #666;
line-height: 1.5;
}
/* Spiel-Layout */
.game-layout {
display: flex;
flex-direction: row;
gap: 20px;
max-width: 1600px;
margin: 0 auto;
padding: 0 20px;
align-items: flex-start;
}
/* Game Canvas Section */
.game-canvas-section {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 500px;
margin: 0 auto; /* Zentriert den gesamten Container */
}
/* Tacho-Display */
.tacho-display {
width: 500px; /* Gleiche Breite wie das zentrale Tile */
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 12px;
margin: 0; /* Kein Margin */
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
box-sizing: border-box; /* Padding wird in die Breite eingerechnet */
}
.tacho-speed {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
font-size: 10pt;
color: #333;
font-weight: 500;
}
.redlight-counter { display: inline-flex; align-items: center; gap: 4px; margin-right: 8px; }
.redlight-icon { font-size: 10pt; }
.redlight-value { font-weight: 700; min-width: 16px; text-align: right; }
.speed-group { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.redlight-counter { white-space: nowrap; }
.tacho-icon {
font-size: 10pt;
}
.lives { font-weight: 700; color: #d60000; }
.fuel { font-weight: 600; color: #0a7c00; margin-left: 8px; }
.score { font-weight: 700; color: #ffa500; margin-left: 8px; }
.speed-violations { font-weight: 700; color: #8a2be2; margin-left: 8px; }
.speed-value {
font-weight: bold;
color: #2196F3;
}
.speed-unit {
color: #666;
font-size: 9pt;
}
.game-board-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0; /* Verhindert Overflow */
}
.game-area {
display: flex;
flex-direction: row;
gap: 20px;
align-items: flex-start;
width: 100%;
max-width: 1200px;
justify-content: center; /* Zentriert den Inhalt */
}
.sidebar-section {
width: 320px;
display: flex;
flex-direction: column;
gap: 20px;
position: sticky;
top: 20px;
}
/* Loaded Passengers Card */
.loaded-passengers-card {
background: #e8f5e8;
border: 1px solid #4caf50;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
margin-bottom: 20px;
}
.loaded-passengers-header {
background: #4caf50;
border-bottom: 1px solid #4caf50;
padding: 5px 20px;
}
.loaded-passengers-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #fff;
}
.loaded-passengers-content {
padding: 5px 20px;
max-height: 200px;
overflow-y: auto;
}
.passengers-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.passengers-table th {
background: #4caf50;
color: white;
padding: 8px 6px;
text-align: left;
font-weight: 600;
font-size: 0.8rem;
}
.passengers-table th:first-child {
width: 25%;
}
.passengers-table th:nth-child(2) {
width: 35%;
}
.passengers-table th:nth-child(3) {
width: 20%;
}
.passengers-table th:last-child {
width: 20%;
}
.passenger-row {
border-bottom: 1px solid #e0e0e0;
}
.passenger-row:last-child {
border-bottom: none;
}
.passenger-row:hover {
background: #f5f5f5;
}
.passenger-name-cell {
padding: 6px;
font-weight: 500;
color: #333;
}
.passenger-destination-cell {
padding: 6px;
color: #2e7d32;
font-size: 0.8rem;
}
.passenger-bonus-cell {
padding: 6px;
color: #ff9800;
font-weight: 600;
text-align: center;
font-size: 0.8rem;
}
.passenger-time-cell {
padding: 6px;
text-align: center;
font-weight: 600;
font-size: 0.8rem;
}
.time-warning {
background: #fff3cd !important;
color: #856404 !important;
}
.time-critical {
background: #f8d7da !important;
color: #721c24 !important;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0.5; }
}
/* Waiting Passengers Card */
.waiting-passengers-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
margin-bottom: 20px;
}
.waiting-passengers-header {
background: #f8f9fa;
border-bottom: 1px solid #ddd;
padding: 5px 20px;
}
.waiting-passengers-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
}
.waiting-passengers-list {
padding: 5px 20px;
max-height: 300px;
overflow-y: auto;
}
.no-passengers {
text-align: center;
color: #666;
font-style: italic;
padding: 5px 0;
}
.passenger-item {
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.passenger-item:last-child {
border-bottom: none;
}
.passenger-item.loaded {
background: #f0f8f0;
padding: 12px;
border-radius: 6px;
border: 1px solid #4caf50;
margin-bottom: 8px;
}
.passenger-item.loaded:last-child {
margin-bottom: 0;
}
.passenger-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.passenger-name {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
.passenger-location {
font-size: 0.85rem;
color: #666;
}
.passenger-destination {
font-size: 0.85rem;
color: #2e7d32;
font-weight: 500;
}
.passenger-timer {
font-size: 0.8rem;
color: #ff5722;
font-weight: bold;
margin-left: 4px;
}
/* Minimap Card */
.minimap-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.minimap-header {
background: #f8f9fa;
border-bottom: 1px solid #ddd;
padding: 5px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.minimap-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: #333;
}
.map-selector {
flex: 0 0 auto;
}
.map-select {
padding: 5px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
background: white;
cursor: pointer;
}
.map-select:focus {
outline: none;
border-color: #F9A22C;
}
.minimap-container {
padding: 15px;
text-align: center;
}
.minimap-canvas {
border: 1px solid #ddd;
border-radius: 4px;
background: #f9f9f9;
max-width: 100%;
height: auto;
}
/* Controls Legend */
.controls-legend {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
width: 200px;
flex-shrink: 0;
}
.controls-legend h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 1.1rem;
text-align: center;
}
.legend-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
}
.legend-key {
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 8px;
font-weight: bold;
font-size: 0.8rem;
min-width: 40px;
text-align: center;
color: #333;
}
.legend-text {
color: #666;
font-size: 0.9rem;
flex: 1;
}
/* Game Objectives in Legend */
.controls-legend .game-objectives {
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.controls-legend .game-objectives h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 1rem;
}
.controls-legend .game-objectives ul {
margin: 0;
padding-left: 15px;
color: #666;
font-size: 0.85rem;
line-height: 1.4;
}
.controls-legend .game-objectives li {
margin-bottom: 4px;
}
/* Game Canvas */
.game-canvas-container {
margin: 0; /* Kein Margin */
text-align: center;
width: 500px; /* Feste Breite wie das Tacho-Display */
height: 500px; /* Feste Höhe beibehalten */
box-sizing: border-box; /* Border wird in die Breite eingerechnet */
}
.game-canvas {
border: 2px solid #ddd;
border-radius: 8px;
background: #f0f0f0;
cursor: crosshair;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
/* Pause Overlay */
.pause-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.pause-message {
background: white;
padding: 40px;
border-radius: 12px;
text-align: center;
box-shadow: 0 8px 16px rgba(0,0,0,0.3);
}
.pause-message h2 {
margin: 0 0 20px 0;
color: #333;
font-size: 1.5rem;
}
.resume-button {
background: #4CAF50;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.3s;
}
.resume-button:hover {
background: #45a049;
}
/* Game Controls */
.game-controls {
display: flex;
gap: 10px;
margin: 20px 0;
flex-wrap: wrap;
justify-content: center;
}
.control-button {
background: #F9A22C;
color: #000;
border: 1px solid #F9A22C;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.control-button:hover {
background: #fdf1db;
color: #7E471B;
border: 1px solid #7E471B;
}
/* Highscore Overlay */
.highscore-overlay {
position: absolute;
top: 0;
left: 0;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(5px);
border: 2px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 10;
}
.highscore-header {
background: #F9A22C;
color: #000;
padding: 20px;
text-align: center;
border-bottom: 2px solid #ddd;
}
.highscore-header h2 {
margin: 0 0 5px 0;
font-size: 1.5rem;
font-weight: 600;
}
.highscore-subtitle {
margin: 0;
font-size: 0.9rem;
opacity: 0.8;
}
.highscore-list {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.loading-message,
.no-highscore {
text-align: center;
padding: 40px 20px;
color: #666;
font-style: italic;
}
.highscore-table {
display: flex;
flex-direction: column;
gap: 8px;
}
.highscore-entry {
display: grid;
grid-template-columns: 50px 1fr auto;
gap: 10px;
align-items: center;
padding: 10px 15px;
background: #f8f9fa;
border-radius: 4px;
transition: all 0.2s;
}
.highscore-entry:hover {
background: #e9ecef;
}
.highscore-entry.current-player {
background: #fff3cd;
border: 2px solid #ffc107;
font-weight: 600;
}
.highscore-entry.current-player:hover {
background: #ffe8a1;
}
.highscore-rank {
font-size: 1.2rem;
font-weight: 700;
color: #F9A22C;
text-align: center;
}
.highscore-name {
font-size: 1rem;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.highscore-points {
font-size: 1rem;
font-weight: 600;
color: #666;
text-align: right;
}
.highscore-separator {
text-align: center;
font-size: 1.5rem;
font-weight: 700;
color: #999;
padding: 10px 0;
letter-spacing: 5px;
}
/* Responsive Design */
@media (max-width: 1024px) {
.game-layout {
flex-direction: column;
}
.sidebar-section {
width: 100%;
position: static;
order: -1; /* Sidebar kommt vor dem Spielbrett */
}
.game-board-section {
width: 100%;
}
.game-area {
flex-direction: column;
align-items: center;
}
.controls-legend {
width: 100%;
max-width: 500px;
}
}
@media (max-width: 768px) {
.game-canvas {
width: 100%;
height: auto;
max-width: 100%;
}
.instructions-grid {
grid-template-columns: 1fr;
}
.game-controls {
flex-direction: column;
align-items: center;
}
.control-button {
width: 200px;
}
.minimap-canvas {
width: 100%;
height: auto;
}
}
.legend-street-item {
cursor: pointer;
}
.legend-street-item.selected {
font-weight: 700;
}
</style>