Änderungen: - Hinzufügen einer ursprünglichen Geschwindigkeit für Autos zur Wiederherstellung nach Kollisionen. - Anpassung der Logik zur Entfernung von Autos mit Geschwindigkeit 0, um gestoppte Autos nach Kollisionen zu berücksichtigen. - Implementierung einer neuen Methode zur Handhabung von Kollisionen zwischen Taxi und Autos, die das Verhalten bei Kollisionen mit stehenden Taxis optimiert. - Erweiterung der Debugging-Ausgaben zur besseren Nachverfolgbarkeit von Fahrzeugbewegungen und Kollisionen. Diese Anpassungen verbessern die Interaktion der Fahrzeuge und optimieren die Kollisionserkennung im Spiel.
5586 lines
202 KiB
Vue
5586 lines
202 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.03; // ~15px bei 500px Tilegröße – breitere Toleranz
|
||
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;
|
||
case 'tdown': {
|
||
// T-Kreuzung: unten, links, rechts offen; oben verboten
|
||
if (!(nearXCenter || nearYCenter)) {
|
||
// Sonderfall: Von oben kommend früher zwingen, bevor die Mitte exakt erreicht ist
|
||
if (car.direction === 'up' && ry <= 0.5 + tol) {
|
||
return Math.random() < 0.5 ? 'left' : 'right';
|
||
}
|
||
return null;
|
||
}
|
||
const choices = [];
|
||
if (car.direction === 'down') {
|
||
// Von oben kommend: NICHT weiter nach unten fahren
|
||
choices.push('left', 'right');
|
||
} else if (car.direction === 'left' || car.direction === 'right') {
|
||
// Geradeaus erlaubt (links/rechts), außerdem nach unten
|
||
choices.push(car.direction, 'down');
|
||
} else if (car.direction === 'up') {
|
||
// Von oben: nur links oder rechts, niemals weiter nach oben
|
||
choices.push('left', 'right');
|
||
}
|
||
if (choices.length === 0) return null;
|
||
return choices[Math.floor(Math.random() * choices.length)];
|
||
}
|
||
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;
|
||
|
||
// Entferne alle Autos vom alten Tile
|
||
this.cars = [];
|
||
|
||
// 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) {
|
||
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) {
|
||
return;
|
||
}
|
||
|
||
// Spawne Auto auf einer befahrbaren Position
|
||
const spawnPosition = this.getRandomCarSpawnPosition();
|
||
if (!spawnPosition) {
|
||
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
|
||
originalSpeed: 0.3 + Math.random() * 0.7, // Ursprüngliche Geschwindigkeit für Wiederherstellung
|
||
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
|
||
laneAxis: spawnPosition.laneAxis,
|
||
laneCoord: spawnPosition.laneCoord,
|
||
willTurn: spawnPosition.willTurn,
|
||
turnDirection: spawnPosition.turnDirection,
|
||
turnX: spawnPosition.turnX,
|
||
turnY: spawnPosition.turnY,
|
||
turnAngle: spawnPosition.turnAngle
|
||
};
|
||
|
||
this.cars.push(car);
|
||
},
|
||
|
||
// 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 ['right', 'down']; // Kommt von links (bewegt sich nach rechts) und von oben (bewegt sich nach unten)
|
||
case 'cornertopright':
|
||
return ['left', 'down']; // Kommt von rechts (bewegt sich nach links) und von oben (bewegt sich nach unten)
|
||
case 'cornerbottomleft':
|
||
return ['right', 'up']; // Kommt von links (bewegt sich nach rechts) und von unten (bewegt sich nach oben)
|
||
case 'cornerbottomright':
|
||
return ['left', 'up']; // Kommt von rechts (bewegt sich nach links) und von unten (bewegt sich nach oben)
|
||
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 ['up', 'left', 'right']; // Fahrzeuge kommen von unten/links/rechts (nicht von oben)
|
||
case 'tleft':
|
||
return ['right', 'up', 'down']; // Kommt von links (bewegt sich nach rechts), von oben (bewegt sich nach unten) und von unten (bewegt sich nach oben)
|
||
case 'tright':
|
||
return ['left', 'up', 'down']; // Kommt von rechts (bewegt sich nach links), von oben (bewegt sich nach unten) und von unten (bewegt sich nach oben)
|
||
default:
|
||
return []; // Keine erlaubten Spawn-Richtungen
|
||
}
|
||
},
|
||
|
||
// Berechnet ob und wo ein Auto abbiegen wird
|
||
calculateTurnInfo(tileType, direction) {
|
||
const tileSize = this.tiles.size;
|
||
|
||
// Für tdown: Autos müssen abbiegen
|
||
if (tileType === 'tdown') {
|
||
if (direction === 'up') {
|
||
// Kommt von unten, muss nach links oder rechts abbiegen
|
||
const turnDirection = Math.random() < 0.5 ? 'left' : 'right';
|
||
const turnX = turnDirection === 'left' ? 0.375 * tileSize : 0.625 * tileSize;
|
||
const turnY = 0.5 * tileSize; // Mitte des Tiles
|
||
const turnAngle = turnDirection === 'left' ? Math.PI : 0;
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: turnDirection,
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
}
|
||
}
|
||
|
||
// Für cornerBottomRight: Verbindet untere und rechte Seite
|
||
// Kurve von unten nach rechts oder von rechts nach unten
|
||
if (tileType === 'cornerbottomright') {
|
||
if (direction === 'left') {
|
||
// Kommt von rechts (Y-Spur ~212.5px = 0.425), biegt nach unten ab (X-Spur ~212.5px = 0.425)
|
||
const laneMargin = 0.03;
|
||
const laneCenter = 0.375 + (0.5 - 0.375) / 2; // Mitte der linken/oberen Spur = 0.4375
|
||
const turnX = laneCenter * tileSize; // X-Position nach dem Abbiegen
|
||
const turnY = laneCenter * tileSize; // Y-Position beim Abbiegen
|
||
const turnAngle = Math.PI / 2; // nach unten
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'down',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
} else if (direction === 'up') {
|
||
// Kommt von unten (X-Spur ~281px = 0.5625), biegt nach rechts ab (Y-Spur ~281px = 0.5625)
|
||
const laneCenter = 0.5 + (0.625 - 0.5) / 2; // Mitte der rechten/unteren Spur = 0.5625
|
||
const turnX = laneCenter * tileSize; // X-Position beim Abbiegen
|
||
const turnY = laneCenter * tileSize; // Y-Position nach dem Abbiegen
|
||
const turnAngle = 0; // nach rechts
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'right',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
}
|
||
}
|
||
|
||
// Für cornerTopRight: Verbindet obere und rechte Seite
|
||
// Kurve von oben nach rechts oder von rechts nach oben
|
||
if (tileType === 'cornertopright') {
|
||
if (direction === 'left') {
|
||
// Kommt von rechts (Y-Spur ~212.5px), biegt nach oben ab (X-Spur ~281px)
|
||
const laneCenterLower = 0.375 + (0.5 - 0.375) / 2; // 0.4375
|
||
const laneCenterUpper = 0.5 + (0.625 - 0.5) / 2; // 0.5625
|
||
const turnX = laneCenterUpper * tileSize; // X-Position nach dem Abbiegen
|
||
const turnY = laneCenterLower * tileSize; // Y-Position beim Abbiegen
|
||
const turnAngle = -Math.PI / 2; // nach oben
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'up',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
} else if (direction === 'down') {
|
||
// Kommt von oben (X-Spur ~281px), biegt nach rechts ab (Y-Spur ~281px)
|
||
const laneCenter = 0.5 + (0.625 - 0.5) / 2; // 0.5625
|
||
const turnX = laneCenter * tileSize;
|
||
const turnY = laneCenter * tileSize;
|
||
const turnAngle = 0; // nach rechts
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'right',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
}
|
||
}
|
||
|
||
// Für cornerTopLeft: Verbindet obere und linke Seite
|
||
// Kurve von oben nach links oder von links nach oben
|
||
if (tileType === 'cornertopleft') {
|
||
if (direction === 'right') {
|
||
// Kommt von links (Y-Spur ~281px), biegt nach oben ab (X-Spur ~281px)
|
||
const laneCenter = 0.5 + (0.625 - 0.5) / 2; // 0.5625
|
||
const turnX = laneCenter * tileSize;
|
||
const turnY = laneCenter * tileSize;
|
||
const turnAngle = -Math.PI / 2; // nach oben
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'up',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
} else if (direction === 'down') {
|
||
// Kommt von oben (X-Spur ~212.5px), biegt nach links ab (Y-Spur ~281px)
|
||
const laneCenterLower = 0.375 + (0.5 - 0.375) / 2; // 0.4375
|
||
const laneCenterUpper = 0.5 + (0.625 - 0.5) / 2; // 0.5625
|
||
const turnX = laneCenterLower * tileSize; // X-Position beim Abbiegen
|
||
const turnY = laneCenterUpper * tileSize; // Y-Position nach dem Abbiegen
|
||
const turnAngle = Math.PI; // nach links
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'left',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
}
|
||
}
|
||
|
||
// Für cornerBottomLeft: Verbindet untere und linke Seite
|
||
// Kurve von unten nach links oder von links nach unten
|
||
if (tileType === 'cornerbottomleft') {
|
||
if (direction === 'right') {
|
||
// Kommt von links (Y-Spur ~281px), biegt nach unten ab (X-Spur ~212.5px)
|
||
const laneCenterLower = 0.375 + (0.5 - 0.375) / 2; // 0.4375
|
||
const laneCenterUpper = 0.5 + (0.625 - 0.5) / 2; // 0.5625
|
||
const turnX = laneCenterLower * tileSize; // X-Position nach dem Abbiegen
|
||
const turnY = laneCenterUpper * tileSize; // Y-Position beim Abbiegen
|
||
const turnAngle = Math.PI / 2; // nach unten
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'down',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
} else if (direction === 'up') {
|
||
// Kommt von unten (X-Spur ~281px), biegt nach links ab (Y-Spur ~212.5px)
|
||
const laneCenterLower = 0.375 + (0.5 - 0.375) / 2; // 0.4375
|
||
const laneCenterUpper = 0.5 + (0.625 - 0.5) / 2; // 0.5625
|
||
const turnX = laneCenterUpper * tileSize; // X-Position beim Abbiegen
|
||
const turnY = laneCenterLower * tileSize; // Y-Position nach dem Abbiegen
|
||
const turnAngle = Math.PI; // nach links
|
||
|
||
return {
|
||
willTurn: true,
|
||
turnDirection: 'left',
|
||
turnX: turnX,
|
||
turnY: turnY,
|
||
turnAngle: turnAngle
|
||
};
|
||
}
|
||
}
|
||
|
||
// Standard: Kein Abbiegen
|
||
return {
|
||
willTurn: false,
|
||
turnDirection: null,
|
||
turnX: null,
|
||
turnY: null,
|
||
turnAngle: null
|
||
};
|
||
},
|
||
|
||
// Finde eine zufällige befahrbare Spawn-Position für ein Auto
|
||
getRandomCarSpawnPosition() {
|
||
if (!this.currentMap || !this.currentMap.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();
|
||
|
||
|
||
|
||
if (allowedDirections.length === 0) {
|
||
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' },
|
||
'up': { pickRelX: () => 0.5 + laneMargin + Math.random() * (0.625 - 0.5 - 2 * laneMargin), relativeY: 1.02, angle: -Math.PI / 2, direction: 'up', laneAxis: 'V' },
|
||
'down': { pickRelX: () => 0.375 + laneMargin + Math.random() * (0.5 - 0.375 - 2 * laneMargin), relativeY: -0.02, angle: Math.PI / 2, direction: 'down', laneAxis: 'V' }
|
||
};
|
||
|
||
// Wähle eine zufällige erlaubte Richtung
|
||
const filtered = allowedDirections;
|
||
const randomDirection = filtered[Math.floor(Math.random() * filtered.length)];
|
||
const spawnPos = spawnPositionsMap[randomDirection];
|
||
|
||
if (!spawnPos) {
|
||
return null;
|
||
}
|
||
|
||
// Berechne ob und wo das Auto abbiegen wird
|
||
const turnInfo = this.calculateTurnInfo(tileType, randomDirection);
|
||
|
||
// Alle Autos spawnen außerhalb des Tiles (normale Positionierung)
|
||
// x und y sind die Mittelpunkte der Spawn-Position
|
||
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;
|
||
let angle = spawnPos.angle;
|
||
let laneCoord;
|
||
|
||
// Berechne laneCoord basierend auf der Mittelpunkt-Position (BEVOR wir zu linker oberer Ecke konvertieren)
|
||
// laneCoord ist IMMER die aktuelle Spur-Position auf der Querachse
|
||
// Beim Abbiegen wird laneCoord später in updateCarMovement aktualisiert
|
||
laneCoord = spawnPos.laneAxis === 'H' ? y : x;
|
||
|
||
// Jetzt konvertiere zu linker oberer Ecke für die Rendering-Position
|
||
// 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;
|
||
}
|
||
|
||
return {
|
||
x: x,
|
||
y: y,
|
||
angle: angle,
|
||
direction: randomDirection,
|
||
laneAxis: spawnPos.laneAxis,
|
||
laneCoord: laneCoord,
|
||
willTurn: turnInfo.willTurn,
|
||
turnDirection: turnInfo.turnDirection,
|
||
turnX: turnInfo.turnX,
|
||
turnY: turnInfo.turnY,
|
||
turnAngle: turnInfo.turnAngle
|
||
};
|
||
},
|
||
|
||
// 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) {
|
||
return true;
|
||
} else {
|
||
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;
|
||
|
||
|
||
|
||
this.cars = this.cars.filter((car, index) => {
|
||
|
||
|
||
// Entferne alte Autos
|
||
// Bewege das Auto
|
||
this.updateCarMovement(car);
|
||
|
||
// Entferne Autos mit speed=0 (off-road), aber nicht gestoppte Autos nach Kollision
|
||
if (car.speed === 0 && !car.isStopped) {
|
||
|
||
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) {
|
||
|
||
return false;
|
||
}
|
||
|
||
|
||
return true;
|
||
});
|
||
|
||
},
|
||
|
||
// Aktualisiere die Bewegung eines einzelnen Autos (vereinfachte Taxi-Logik)
|
||
updateCarMovement(car) {
|
||
// Wenn das Auto gestoppt ist (nach Kollision mit stehendem Taxi), prüfe ob Taxi weg ist
|
||
if (car.isStopped) {
|
||
// Prüfe ob das Taxi noch in der Nähe ist (Kollisionsabstand + 50px Puffer)
|
||
const distance = Math.sqrt(
|
||
Math.pow(car.x - this.taxi.x, 2) + Math.pow(car.y - this.taxi.y, 2)
|
||
);
|
||
const collisionDistance = 80; // Etwa die Größe der Kollisionsboxen
|
||
|
||
console.log(`Auto ${car.id} gestoppt - Distanz zum Taxi: ${distance.toFixed(1)}px`);
|
||
|
||
if (distance > 30) {
|
||
// Taxi ist weg - Auto kann wieder fahren
|
||
car.isStopped = false;
|
||
car.speed = car.originalSpeed || 0.5; // Ursprüngliche Geschwindigkeit wiederherstellen
|
||
console.log(`Auto ${car.id} kann wieder fahren - Taxi ist weg (Distanz: ${distance.toFixed(1)}px)`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 0) Prüfe ob das Auto abbiegen sollte (neue Turn-Logik)
|
||
if (car.willTurn && !car.hasTurnedAtIntersection) {
|
||
// Auto soll abbiegen - prüfe ob es die Turn-Position erreicht oder überschritten hat
|
||
const carCenterX = car.x + car.width / 2;
|
||
const carCenterY = car.y + car.height / 2;
|
||
|
||
let shouldTurn = false;
|
||
|
||
// Prüfe ob das Auto die Turn-Position erreicht hat (abhängig von der Fahrtrichtung)
|
||
if (car.direction === 'up') {
|
||
// Fährt nach oben - prüfe ob Y-Position erreicht ist
|
||
shouldTurn = carCenterY <= car.turnY;
|
||
} else if (car.direction === 'down') {
|
||
// Fährt nach unten - prüfe ob Y-Position erreicht ist
|
||
shouldTurn = carCenterY >= car.turnY;
|
||
} else if (car.direction === 'left') {
|
||
// Fährt nach links - prüfe ob X-Position erreicht ist
|
||
shouldTurn = carCenterX <= car.turnX;
|
||
} else if (car.direction === 'right') {
|
||
// Fährt nach rechts - prüfe ob X-Position erreicht ist
|
||
shouldTurn = carCenterX >= car.turnX;
|
||
}
|
||
|
||
if (shouldTurn) {
|
||
// Merke alte Richtung und Position für Animation
|
||
const oldDirection = car.direction;
|
||
|
||
car.direction = car.turnDirection;
|
||
car.hasTurnedAtIntersection = true;
|
||
|
||
// Starte Animation für sanfte Drehung (0.5 Sekunden)
|
||
car.isTurning = true;
|
||
car.turnStartAngle = car.angle;
|
||
car.turnTargetAngle = car.turnAngle;
|
||
car.turnStartTime = Date.now();
|
||
car.turnDuration = 250; // 0.25 Sekunden
|
||
|
||
// Speichere Start- und Ziel-Position für smooth Interpolation
|
||
car.turnStartX = car.x;
|
||
car.turnStartY = car.y;
|
||
|
||
// Berechne Ziel-Position
|
||
if (oldDirection === 'up' || oldDirection === 'down') {
|
||
// War vertikal, wird horizontal - setze Y-Position auf turnY
|
||
car.turnTargetY = car.turnY - car.height / 2;
|
||
car.turnTargetX = car.x; // X bleibt gleich
|
||
} else {
|
||
// War horizontal, wird vertikal - setze X-Position auf turnX
|
||
car.turnTargetX = car.turnX - car.width / 2;
|
||
car.turnTargetY = car.y; // Y bleibt gleich
|
||
}
|
||
|
||
// Aktualisiere laneAxis und laneCoord für die neue Richtung
|
||
// laneCoord ist die Position auf der Querachse (wo das Auto bleiben soll)
|
||
if (car.turnDirection === 'left' || car.turnDirection === 'right') {
|
||
// Fährt jetzt horizontal - X ändert sich, Y bleibt fix
|
||
car.laneAxis = 'H';
|
||
car.laneCoord = car.turnY; // Bleibe auf dieser Y-Position
|
||
} else {
|
||
// Fährt jetzt vertikal - Y ändert sich, X bleibt fix
|
||
car.laneAxis = 'V';
|
||
car.laneCoord = car.turnX; // Bleibe auf dieser X-Position
|
||
}
|
||
}
|
||
}
|
||
|
||
// Animiere Drehung wenn Auto gerade abbiegt
|
||
if (car.isTurning) {
|
||
const now = Date.now();
|
||
const elapsed = now - car.turnStartTime;
|
||
const progress = Math.min(elapsed / car.turnDuration, 1.0); // 0.0 bis 1.0
|
||
|
||
// Easing-Funktion für smooth animation (ease-in-out)
|
||
const eased = progress < 0.5
|
||
? 2 * progress * progress
|
||
: 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
||
|
||
// Normalisiere Winkel in [-π, π]
|
||
const normalizeAngle = (a) => {
|
||
while (a > Math.PI) a -= 2 * Math.PI;
|
||
while (a < -Math.PI) a += 2 * Math.PI;
|
||
return a;
|
||
};
|
||
|
||
// Interpoliere Winkel
|
||
let startAngle = normalizeAngle(car.turnStartAngle);
|
||
let targetAngle = normalizeAngle(car.turnTargetAngle);
|
||
|
||
// Kürzesten Weg für Rotation finden mit atan2
|
||
// Berechne den Unterschied und verwende atan2 für korrekten Quadranten
|
||
let diff = Math.atan2(Math.sin(targetAngle - startAngle), Math.cos(targetAngle - startAngle));
|
||
|
||
// Debug: Log bei Start der Animation
|
||
if (elapsed < 50 && !car._loggedTurn) {
|
||
console.log(`Turn animation: start=${(startAngle * 180 / Math.PI).toFixed(1)}° target=${(targetAngle * 180 / Math.PI).toFixed(1)}° diff=${(diff * 180 / Math.PI).toFixed(1)}°`);
|
||
car._loggedTurn = true;
|
||
}
|
||
|
||
car.angle = normalizeAngle(startAngle + diff * eased);
|
||
|
||
// Animation beenden wenn fertig
|
||
if (progress >= 1.0) {
|
||
car.isTurning = false;
|
||
car.angle = targetAngle;
|
||
}
|
||
}
|
||
|
||
// 1) Alte Turn-Logik (nur für Autos ohne vorberechnete Turns - z.B. T-Kreuzungen ohne willTurn)
|
||
// Diese Logik wird nur noch für Tile-Typen ohne vorberechnete Turn-Info verwendet
|
||
let forcedDir = null;
|
||
if (!car.hasTurnedAtIntersection && !car.willTurn) {
|
||
forcedDir = this.shouldCarTurnAtIntersection(car);
|
||
// Strikter Zwang für T-Down: von unten kommend (direction 'up') niemals geradeaus weiter
|
||
const streetType = this.mapTileTypeToStreetCoordinates(this.getCurrentTileType());
|
||
if (!forcedDir && streetType === 'tDown' && car.direction === 'up') {
|
||
const cy = car.y + car.height / 2;
|
||
const tolAbs = 0.03 * this.tiles.size; // ~15px
|
||
const centerYAbs = 0.5 * this.tiles.size;
|
||
if (Math.abs(cy - centerYAbs) <= tolAbs || cy <= centerYAbs + tolAbs) {
|
||
forcedDir = Math.random() < 0.5 ? 'left' : 'right';
|
||
}
|
||
}
|
||
}
|
||
if (forcedDir) {
|
||
const tileType = this.getCurrentTileType();
|
||
// forcedDir hat Vorrang – NICHT durch allowedDirections einschränken
|
||
let preferred = forcedDir;
|
||
|
||
if (preferred && preferred !== car.direction) {
|
||
|
||
// 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 - Auto bleibt auf seiner Spur
|
||
// Während der Turn-Animation: Sanft zur neuen Spur gleiten
|
||
if (car.laneAxis === 'H') {
|
||
// Horizontale Bewegung - Y-Position fixieren
|
||
const centerY = car.laneCoord;
|
||
newY = centerY - car.height / 2;
|
||
} else if (car.laneAxis === 'V') {
|
||
// Vertikale Bewegung - X-Position fixieren
|
||
const centerX = car.laneCoord;
|
||
newX = centerX - car.width / 2;
|
||
}
|
||
|
||
// 3) Position übernehmen (vorher letzte Position merken)
|
||
car.lastPosition = { x: car.x, y: car.y };
|
||
car.x = newX;
|
||
car.y = newY;
|
||
|
||
|
||
|
||
// 4) Straße prüfen – bleibt es auf befahrbarer Fläche?
|
||
if (!this.isCarOnRoad(car)) {
|
||
// Keine harte Korrektur und kein Stoppen: Autos dürfen weiterfahren,
|
||
// Entfernen erfolgt erst, wenn das Fahrzeug komplett außerhalb ist.
|
||
// Optional könnten wir hier eine leichte Korrektur zur Spurmitte einbauen.
|
||
}
|
||
},
|
||
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 = null;
|
||
}
|
||
if (this.rafId) {
|
||
cancelAnimationFrame(this.rafId);
|
||
this.rafId = null;
|
||
}
|
||
this.rafId = requestAnimationFrame(this.update);
|
||
},
|
||
|
||
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++;
|
||
// (Logs entfernt)
|
||
|
||
// 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 (ohne Drosselung)
|
||
const nowTs = Date.now();
|
||
this.updatePassengerTimers();
|
||
this.lastPassengerTimerUpdate = nowTs;
|
||
|
||
this.checkCollisions();
|
||
this.render();
|
||
|
||
// Radar-Messung prüfen (falls aktiv)
|
||
this.checkRadarMeasurement();
|
||
|
||
// Minimap jedes Frame zeichnen
|
||
this.drawMinimap();
|
||
this.lastMinimapDraw = nowTs;
|
||
|
||
// Nächsten Frame planen
|
||
this.rafId = requestAnimationFrame(this.update);
|
||
},
|
||
|
||
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.handleCarTaxiCollision(car);
|
||
}
|
||
});
|
||
}
|
||
},
|
||
// 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;
|
||
},
|
||
|
||
handleCarTaxiCollision(car) {
|
||
// Wenn das Taxi steht (speed = 0), soll das Auto zurückfahren und stehen bleiben
|
||
if (Math.abs(this.taxi.speed) < 0.1) {
|
||
// Auto 10 Pixel zurückfahren in entgegengesetzter Richtung
|
||
const backDistance = 10;
|
||
|
||
if (car.direction === 'up') {
|
||
car.y += backDistance;
|
||
} else if (car.direction === 'down') {
|
||
car.y -= backDistance;
|
||
} else if (car.direction === 'left') {
|
||
car.x += backDistance;
|
||
} else if (car.direction === 'right') {
|
||
car.x -= backDistance;
|
||
}
|
||
|
||
// Auto zum Stehen bringen
|
||
car.speed = 0;
|
||
car.isStopped = true;
|
||
|
||
console.log(`Auto ${car.id} ist mit stehendem Taxi kollidiert und steht jetzt`);
|
||
} else {
|
||
// Wenn das Taxi fährt, normalen Crash behandeln
|
||
this.handleCrash('auto');
|
||
}
|
||
},
|
||
|
||
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() {
|
||
// (Log entfernt)
|
||
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 = null;
|
||
if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
|
||
this.rafId = requestAnimationFrame(this.update);
|
||
|
||
// 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.95); // Länge ~95% (größer für bessere Kollision)
|
||
h = Math.max(0, rect.height * 0.80); // Breite ~80% (größer für bessere Kollision)
|
||
} else if (rect.isCar) {
|
||
w = Math.max(0, rect.width * 0.90); // Länge ~90% (54px von 60px)
|
||
h = Math.max(0, rect.height * 0.50); // Breite nur ~50% (25px von 50px) wegen schmalem SVG
|
||
}
|
||
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);
|
||
|
||
const local = [
|
||
{ x: -hw, y: -hh },
|
||
{ x: hw, y: -hh },
|
||
{ x: hw, y: hh },
|
||
{ x: -hw, y: hh }
|
||
];
|
||
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() {
|
||
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);
|
||
|
||
|
||
|
||
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);
|
||
|
||
// Verwende car.angle für smooth rotation während des Abbiegens
|
||
// Auto-Bild korrigieren (90° + 180° für korrekte Ausrichtung)
|
||
const finalAngle = car.angle + Math.PI / 2 + Math.PI;
|
||
this.ctx.rotate(finalAngle);
|
||
|
||
if (this.carImage) {
|
||
// Zeichne Auto-Bild zentriert um den Transformationspunkt
|
||
this.ctx.drawImage(
|
||
this.carImage,
|
||
-car.width/2,
|
||
-car.height/2,
|
||
car.width,
|
||
car.height
|
||
);
|
||
} else {
|
||
// 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 (deaktiviert)
|
||
// 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;
|
||
event.preventDefault();
|
||
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
|
||
}
|
||
// Keydown-Repeat und Mehrfach-Setzen unterdrücken
|
||
if (event.repeat) return;
|
||
if (this.keys[key]) return;
|
||
|
||
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.rafId) {
|
||
this.rafId = requestAnimationFrame(this.update);
|
||
}
|
||
|
||
// 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>
|