Ä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.
5234 lines
189 KiB
Vue
5234 lines
189 KiB
Vue
<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>
|