Änderung: Verbesserung der Socket-Verbindung und Implementierung von Autos im Taxi-Spiel

Änderungen:
- Hinzufügen von Logik zur Verwaltung von Backend- und Daemon-Verbindungsversuchen mit Retry-Mechanismus.
- Implementierung einer Autos-Generierung mit zufälligen Spawn-Positionen und Bewegungslogik.
- Einführung einer Minimap zur Anzeige der aktuellen Spielumgebung.
- Optimierung der Kollisionserkennung zwischen Taxi und Autos.
- Verbesserung der Speicherbereinigung und Performance durch throttling von Timer-Updates.

Diese Anpassungen erweitern die Spielmechanik und Benutzererfahrung, indem sie die Interaktivität und die grafische Darstellung im Taxi-Spiel verbessern.
This commit is contained in:
Torsten Schulz (local)
2025-10-06 11:58:51 +02:00
parent 1bde46430b
commit 828e45df35
2 changed files with 829 additions and 156 deletions

View File

@@ -11,6 +11,13 @@ const store = createStore({
connectionStatus: 'disconnected', // 'connected', 'connecting', 'disconnected', 'error'
daemonConnectionStatus: 'disconnected', // 'connected', 'connecting', 'disconnected', 'error'
user: JSON.parse(localStorage.getItem('user')) || null,
// Reconnect state management
backendRetryCount: 0,
daemonRetryCount: 0,
backendRetryTimer: null,
daemonRetryTimer: null,
backendConnecting: false,
daemonConnecting: false,
language: (() => {
// Verwende die gleiche Logik wie in main.js
const browserLanguage = navigator.language || navigator.languages[0];
@@ -112,6 +119,12 @@ const store = createStore({
state.socket.disconnect();
}
state.socket = null;
// Cleanup retry timer
if (state.backendRetryTimer) {
clearTimeout(state.backendRetryTimer);
state.backendRetryTimer = null;
}
state.backendConnecting = false;
},
setDaemonSocket(state, daemonSocket) {
state.daemonSocket = daemonSocket;
@@ -122,6 +135,12 @@ const store = createStore({
}
state.daemonSocket = null;
state.daemonConnectionStatus = 'disconnected';
// Cleanup retry timer
if (state.daemonRetryTimer) {
clearTimeout(state.daemonRetryTimer);
state.daemonRetryTimer = null;
}
state.daemonConnecting = false;
},
},
actions: {
@@ -142,74 +161,97 @@ const store = createStore({
commit('dologout');
router.push('/');
},
initializeSocket({ commit, state }) {
if (state.isLoggedIn && state.user) {
let currentSocket = state.socket;
const connectSocket = () => {
if (currentSocket) {
currentSocket.disconnect();
}
commit('setConnectionStatus', 'connecting');
// Socket.io URL für lokale Entwicklung und Produktion
let socketIoUrl = import.meta.env.VITE_SOCKET_IO_URL || import.meta.env.VITE_API_BASE_URL;
// Für lokale Entwicklung: direkte Backend-Verbindung
if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
socketIoUrl = 'http://localhost:3001';
}
const socket = io(socketIoUrl, {
secure: true,
transports: ['websocket', 'polling']
});
socket.on('connect', () => {
retryCount = 0; // Reset retry counter on successful connection
commit('setConnectionStatus', 'connected');
const idForSocket = state.user?.hashedId || state.user?.id;
if (idForSocket) socket.emit('setUserId', idForSocket);
});
socket.on('disconnect', (reason) => {
commit('setConnectionStatus', 'disconnected');
retryConnection(connectSocket);
});
socket.on('connect_error', (error) => {
commit('setConnectionStatus', 'error');
});
commit('setSocket', socket);
};
let retryCount = 0;
const maxRetries = 10;
const retryConnection = (reconnectFn) => {
console.log(`Backend-Reconnect-Versuch ${retryCount + 1}/${maxRetries}`);
if (retryCount >= maxRetries) {
// Nach maxRetries alle 5 Sekunden weiter versuchen
console.log('Backend: Max Retries erreicht, versuche weiter alle 5 Sekunden...');
setTimeout(() => {
retryCount = 0; // Reset für nächsten Zyklus
reconnectFn();
}, 5000);
return;
}
retryCount++;
const delay = 5000; // Alle 5 Sekunden versuchen
console.log(`Backend: Warte ${delay}ms bis zum nächsten Reconnect-Versuch...`);
setTimeout(() => {
reconnectFn();
}, delay);
};
connectSocket();
}
},
initializeDaemonSocket({ commit, state }) {
if (!state.isLoggedIn || !state.user) {
initializeSocket({ commit, state, dispatch }) {
if (!state.isLoggedIn || !state.user || state.backendConnecting) {
return;
}
state.backendConnecting = true;
const connectSocket = () => {
// Cleanup existing socket and timer
if (state.socket) {
state.socket.disconnect();
}
if (state.backendRetryTimer) {
clearTimeout(state.backendRetryTimer);
state.backendRetryTimer = null;
}
commit('setConnectionStatus', 'connecting');
// Socket.io URL für lokale Entwicklung und Produktion
let socketIoUrl = import.meta.env.VITE_SOCKET_IO_URL || import.meta.env.VITE_API_BASE_URL;
// Für lokale Entwicklung: direkte Backend-Verbindung
if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
socketIoUrl = 'http://localhost:3001';
}
const socket = io(socketIoUrl, {
secure: true,
transports: ['websocket', 'polling']
});
socket.on('connect', () => {
state.backendRetryCount = 0; // Reset retry counter on successful connection
state.backendConnecting = false;
commit('setConnectionStatus', 'connected');
const idForSocket = state.user?.hashedId || state.user?.id;
if (idForSocket) socket.emit('setUserId', idForSocket);
});
socket.on('disconnect', (reason) => {
commit('setConnectionStatus', 'disconnected');
dispatch('retryBackendConnection');
});
socket.on('connect_error', (error) => {
commit('setConnectionStatus', 'error');
dispatch('retryBackendConnection');
});
commit('setSocket', socket);
};
connectSocket();
},
retryBackendConnection({ commit, state }) {
if (state.backendRetryTimer || state.backendConnecting) {
return; // Already retrying or connecting
}
const maxRetries = 10;
console.log(`Backend-Reconnect-Versuch ${state.backendRetryCount + 1}/${maxRetries}`);
if (state.backendRetryCount >= maxRetries) {
// Nach maxRetries alle 5 Sekunden weiter versuchen
console.log('Backend: Max Retries erreicht, versuche weiter alle 5 Sekunden...');
state.backendRetryTimer = setTimeout(() => {
state.backendRetryCount = 0; // Reset für nächsten Zyklus
state.backendRetryTimer = null;
commit('setConnectionStatus', 'connecting');
// Recursive call to retry
setTimeout(() => this.dispatch('retryBackendConnection'), 100);
}, 5000);
return;
}
state.backendRetryCount++;
const delay = 5000; // Alle 5 Sekunden versuchen
console.log(`Backend: Warte ${delay}ms bis zum nächsten Reconnect-Versuch...`);
state.backendRetryTimer = setTimeout(() => {
state.backendRetryTimer = null;
this.dispatch('initializeSocket');
}, delay);
},
initializeDaemonSocket({ commit, state, dispatch }) {
if (!state.isLoggedIn || !state.user || state.daemonConnecting) {
return;
}
state.daemonConnecting = true;
// Daemon URL für lokale Entwicklung und Produktion
let daemonUrl = import.meta.env.VITE_DAEMON_SOCKET;
@@ -224,8 +266,16 @@ const store = createStore({
daemonUrl = 'wss://www.your-part.de:4551';
}
const connectDaemonSocket = () => {
// Cleanup existing socket and timer
if (state.daemonSocket) {
state.daemonSocket.close();
}
if (state.daemonRetryTimer) {
clearTimeout(state.daemonRetryTimer);
state.daemonRetryTimer = null;
}
// Protokoll-Fallback: zuerst mit Subprotokoll, dann ohne
const protocols = ['yourpart-protocol', undefined];
let attemptIndex = 0;
@@ -242,7 +292,8 @@ const store = createStore({
daemonSocket.onopen = () => {
opened = true;
retryCount = 0; // Reset retry counter on successful connection
state.daemonRetryCount = 0; // Reset retry counter on successful connection
state.daemonConnecting = false;
commit('setDaemonConnectionStatus', 'connected');
const payload = JSON.stringify({
event: 'setUserId',
@@ -259,7 +310,7 @@ const store = createStore({
tryConnectWithProtocol();
return;
}
retryConnection(connectDaemonSocket);
dispatch('retryDaemonConnection');
};
daemonSocket.onerror = (error) => {
@@ -270,7 +321,7 @@ const store = createStore({
tryConnectWithProtocol();
return;
}
retryConnection(connectDaemonSocket);
dispatch('retryDaemonConnection');
};
daemonSocket.addEventListener('message', (event) => {
@@ -295,35 +346,45 @@ const store = createStore({
tryConnectWithProtocol();
return;
}
retryConnection(connectDaemonSocket);
dispatch('retryDaemonConnection');
}
};
tryConnectWithProtocol();
};
let retryCount = 0;
const maxRetries = 15; // Increased max retries
const retryConnection = (reconnectFn) => {
console.log(`Daemon-Reconnect-Versuch ${retryCount + 1}/${maxRetries}`);
if (retryCount >= maxRetries) {
// Nach maxRetries alle 5 Sekunden weiter versuchen
console.log('Daemon: Max Retries erreicht, versuche weiter alle 5 Sekunden...');
setTimeout(() => {
retryCount = 0; // Reset für nächsten Zyklus
reconnectFn();
}, 5000);
return;
}
retryCount++;
const delay = 5000; // Alle 5 Sekunden versuchen
console.log(`Daemon: Warte ${delay}ms bis zum nächsten Reconnect-Versuch...`);
setTimeout(() => {
reconnectFn();
}, delay);
};
connectDaemonSocket();
},
retryDaemonConnection({ commit, state }) {
if (state.daemonRetryTimer || state.daemonConnecting) {
return; // Already retrying or connecting
}
const maxRetries = 15;
console.log(`Daemon-Reconnect-Versuch ${state.daemonRetryCount + 1}/${maxRetries}`);
if (state.daemonRetryCount >= maxRetries) {
// Nach maxRetries alle 5 Sekunden weiter versuchen
console.log('Daemon: Max Retries erreicht, versuche weiter alle 5 Sekunden...');
state.daemonRetryTimer = setTimeout(() => {
state.daemonRetryCount = 0; // Reset für nächsten Zyklus
state.daemonRetryTimer = null;
commit('setDaemonConnectionStatus', 'connecting');
// Recursive call to retry
setTimeout(() => this.dispatch('retryDaemonConnection'), 100);
}, 5000);
return;
}
state.daemonRetryCount++;
const delay = 5000; // Alle 5 Sekunden versuchen
console.log(`Daemon: Warte ${delay}ms bis zum nächsten Reconnect-Versuch...`);
state.daemonRetryTimer = setTimeout(() => {
state.daemonRetryTimer = null;
this.dispatch('initializeDaemonSocket');
}, delay);
},
setLanguage({ commit }, language) {
commit('setLanguage', language);
},
@@ -352,7 +413,8 @@ const store = createStore({
},
});
if (store.state.isLoggedIn && store.state.user) {
// Initialisierung beim Laden der Anwendung - nur einmal ausführen
if (store.state.isLoggedIn && store.state.user && !store.state.backendConnecting && !store.state.daemonConnecting) {
store.dispatch('initializeSocket');
store.dispatch('initializeDaemonSocket');
}

View File

@@ -162,6 +162,32 @@
<!-- 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">
@@ -208,49 +234,16 @@
<div v-if="waitingPassengersList.length === 0" class="no-passengers">
Keine wartenden Passagiere
</div>
<div v-else>
<div
v-for="(passenger, index) in waitingPassengersList"
:key="index"
class="passenger-item"
>
<div class="passenger-info">
<span class="passenger-name">
{{ passenger.name }}
<span v-if="getPassengerTimeLeft(passenger) <= 5" class="passenger-timer">({{ getPassengerTimeLeft(passenger) }}s)</span>
</span>
<span class="passenger-location">{{ passenger.location }}</span>
</div>
</div>
</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>
<!-- 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>
</div>
</div>
@@ -328,6 +321,7 @@ export default {
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)
@@ -360,6 +354,12 @@ export default {
,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: 0.2 // 20% Wahrscheinlichkeit pro Sekunde
}
},
computed: {
@@ -401,15 +401,22 @@ export default {
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() {
@@ -738,19 +745,25 @@ export default {
},
cleanup() {
console.log('🧹 Starting cleanup...');
// Game Loop stoppen
if (this.gameLoop) {
cancelAnimationFrame(this.gameLoop);
this.gameLoop = null;
}
if (this.motorSound) { this.motorSound.stop(); }
// Motor Sound stoppen
if (this.motorSound) {
this.motorSound.stop();
this.motorSound = null;
}
// Alle Timeouts bereinigen
if (this.motorStopTimeout) {
clearTimeout(this.motorStopTimeout);
this.motorStopTimeout = null;
}
// Event-Listener von Document entfernen
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
// Cleanup aller Timeouts
if (this.passengerGenerationTimeout) {
clearTimeout(this.passengerGenerationTimeout);
this.passengerGenerationTimeout = null;
@@ -759,17 +772,116 @@ export default {
clearTimeout(this.crashDialogTimeout);
this.crashDialogTimeout = null;
}
// AudioContext bleibt global erhalten, nicht schließen
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;
}
// Cleanup von Listen und Sets
// 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() {
@@ -1229,6 +1341,363 @@ export default {
// 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
if (this.cars.length >= 8) {
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
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
};
this.cars.push(car);
},
// 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;
// Definiere die erlaubten Spawn-Positionen (relativ 0-1)
// Mit korrekter Straßenseite basierend auf echten Straßenkoordinaten
const spawnPositions = [
// Links spawnen, nach rechts fahren, auf der rechten Straßenseite (y=0.5-0.625)
{ relativeX: 0.1, relativeY: 0.5 + Math.random() * 0.125, angle: 0, direction: 'right' },
// Rechts spawnen, nach links fahren, auf der linken Straßenseite (y=0.375-0.5)
{ relativeX: 0.9, relativeY: 0.375 + Math.random() * 0.125, angle: Math.PI, direction: 'left' },
// Oben spawnen, nach unten fahren, auf der linken Straßenseite (x=0.375-0.5)
{ relativeX: 0.375 + Math.random() * 0.125, relativeY: 0.1, angle: Math.PI / 2, direction: 'down' },
// Unten spawnen, nach oben fahren, auf der rechten Straßenseite (x=0.5-0.625)
{ relativeX: 0.5 + Math.random() * 0.125, relativeY: 0.9, angle: -Math.PI / 2, direction: 'up' }
];
// Wähle eine zufällige Spawn-Position
const spawnPos = spawnPositions[Math.floor(Math.random() * spawnPositions.length)];
// Konvertiere relative Koordinaten zu absoluten Pixeln
const x = spawnPos.relativeX * tileSize;
const y = spawnPos.relativeY * tileSize;
// Prüfe ob Position befahrbar ist
if (this.isPositionDriveable(x, y)) {
return {
x: x,
y: y,
angle: spawnPos.angle,
direction: spawnPos.direction
};
}
// Fallback: Versuche andere Positionen
for (let i = 0; i < spawnPositions.length; i++) {
const pos = spawnPositions[i];
const testX = pos.relativeX * tileSize;
const testY = pos.relativeY * tileSize;
if (this.isPositionDriveable(testX, testY)) {
return {
x: testX,
y: testY,
angle: pos.angle,
direction: pos.direction
};
}
}
return null; // Keine gültige Position gefunden
},
// 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);
},
// 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
this.cars = this.cars.filter(car => {
// Entferne alte Autos
if (now - car.createdAt > maxAge) {
return false;
}
// Bewege das Auto nur wenn es auf einer befahrbaren Position ist
this.updateCarMovement(car);
// Entferne Autos, die außerhalb des Bildschirms sind
if (car.x < -50 || car.x > 550 || car.y < -50 || car.y > 550) {
return false;
}
return true;
});
},
// Aktualisiere die Bewegung eines einzelnen Autos
updateCarMovement(car) {
// Speichere aktuelle Position
car.lastPosition = { x: car.x, y: car.y };
// Setze Zielrichtung beim ersten Betreten des Tiles
if (!car.targetDirection) {
car.targetDirection = this.getRandomTargetDirection(car.direction);
car.targetLane = this.getTargetLane(car.targetDirection);
}
// Prüfe ob Auto an einer Kreuzung ist und zur Zielrichtung abbiegen soll
if (this.shouldCarTurnAtIntersection(car) && !car.hasTurnedAtIntersection) {
this.turnCarToTarget(car);
car.hasTurnedAtIntersection = true;
}
// Berechne neue Position
const newX = car.x + Math.cos(car.angle) * car.speed;
const newY = car.y + Math.sin(car.angle) * car.speed;
// Prüfe ob neue Position befahrbar ist
if (this.isPositionDriveable(newX, newY)) {
// Position ist befahrbar - bewege Auto
car.x = newX;
car.y = newY;
} else {
// Vorzugsweise zur geplanten Zielrichtung abbiegen (z. B. Kurve unten->rechts)
if (car.targetDirection) {
const prevAngle = car.angle;
const prevDir = car.direction;
this.turnCarToTarget(car);
const tryX = car.x + Math.cos(car.angle) * car.speed;
const tryY = car.y + Math.sin(car.angle) * car.speed;
if (this.isPositionDriveable(tryX, tryY)) {
car.x = tryX;
car.y = tryY;
} else {
// Rückgängig machen, falls Zielrichtung auch nicht geht
car.angle = prevAngle;
car.direction = prevDir;
// Fallback: alternative Richtung suchen (ohne zurückzufahren)
this.adjustCarDirection(car);
}
} else {
// Position ist nicht befahrbar - suche alternative Richtung (ohne zurückzufahren)
this.adjustCarDirection(car);
}
}
},
// Bestimme zufällige Zielrichtung basierend auf Tile-Art und aktueller Richtung
getRandomTargetDirection(currentDirection) {
const tileType = this.getCurrentTileType();
const possibleDirections = [];
// Bestimme mögliche Richtungen basierend auf Tile-Typ
switch (tileType) {
case 'cornerTopLeft':
// L-förmige Kurve: oben-links
if (currentDirection === 'right') possibleDirections.push('up');
else if (currentDirection === 'down') possibleDirections.push('left');
break;
case 'cornerTopRight':
// L-förmige Kurve: oben-rechts
if (currentDirection === 'left') possibleDirections.push('up');
else if (currentDirection === 'down') possibleDirections.push('right');
break;
case 'cornerBottomLeft':
// L-förmige Kurve: unten-links
if (currentDirection === 'right') possibleDirections.push('down');
else if (currentDirection === 'up') possibleDirections.push('left');
break;
case 'cornerBottomRight':
// L-förmige Kurve: unten-rechts
if (currentDirection === 'left') possibleDirections.push('down');
else if (currentDirection === 'up') possibleDirections.push('right');
break;
case 'crossing':
// Kreuzung: alle Richtungen außer zurück
switch (currentDirection) {
case 'right': possibleDirections.push('up', 'down'); break;
case 'left': possibleDirections.push('up', 'down'); break;
case 'up': possibleDirections.push('left', 'right'); break;
case 'down': possibleDirections.push('left', 'right'); break;
}
break;
default:
// Für andere Tile-Typen: nur geradeaus
possibleDirections.push(currentDirection);
break;
}
// Fallback: wenn keine Richtungen gefunden, geradeaus
if (possibleDirections.length === 0) {
possibleDirections.push(currentDirection);
}
return possibleDirections[Math.floor(Math.random() * possibleDirections.length)];
},
// Bestimme Zielspur basierend auf Zielrichtung
getTargetLane(targetDirection) {
switch (targetDirection) {
case 'left':
return { y: 0.375 + Math.random() * 0.125 }; // y=0.375-0.5
case 'right':
return { y: 0.5 + Math.random() * 0.125 }; // y=0.5-0.625
case 'down':
return { x: 0.375 + Math.random() * 0.125 }; // x=0.375-0.5
case 'up':
return { x: 0.5 + Math.random() * 0.125 }; // x=0.5-0.625
}
},
// Prüfe ob Auto an Kreuzung zur Zielrichtung abbiegen soll
shouldCarTurnAtIntersection(car) {
const relativeX = car.x / this.tiles.size;
const relativeY = car.y / this.tiles.size;
// Prüfe ob Auto sehr nah an der Straßenmitte ist (genauere Kreuzungserkennung)
const atCenter = (relativeX > 0.48 && relativeX < 0.52) && (relativeY > 0.48 && relativeY < 0.52);
return atCenter;
},
// Auto zur Zielrichtung abbiegen
turnCarToTarget(car) {
const targetDirection = car.targetDirection;
switch (targetDirection) {
case 'left':
car.angle = Math.PI;
car.direction = 'left';
break;
case 'right':
car.angle = 0;
car.direction = 'right';
break;
case 'down':
car.angle = Math.PI / 2;
car.direction = 'down';
break;
case 'up':
car.angle = -Math.PI / 2;
car.direction = 'up';
break;
}
},
// Passe die Richtung eines Autos an, wenn es nicht weiterfahren kann
adjustCarDirection(car) {
const directions = [
{ angle: 0, name: 'right' },
{ angle: Math.PI, name: 'left' },
{ angle: Math.PI / 2, name: 'down' },
{ angle: -Math.PI / 2, name: 'up' }
];
// Gegengerichtete Richtung (zurückfahren) ausschließen
const opposite = {
right: 'left',
left: 'right',
up: 'down',
down: 'up'
}[car.direction];
// Versuche verschiedene Richtungen, zuerst die geplante Zielrichtung, dann andere (ohne zurück)
const preferredOrder = [];
if (car.targetDirection && car.targetDirection !== opposite) preferredOrder.push(car.targetDirection);
directions.forEach(d => {
if (d.name !== opposite && d.name !== (preferredOrder[0] || '')) preferredOrder.push(d.name);
});
for (let i = 0; i < preferredOrder.length; i++) {
const name = preferredOrder[i];
const angle = name === 'right' ? 0 : (name === 'left' ? Math.PI : (name === 'down' ? Math.PI/2 : -Math.PI/2));
const testX = car.x + Math.cos(angle) * car.speed;
const testY = car.y + Math.sin(angle) * car.speed;
if (this.isPositionDriveable(testX, testY)) {
car.angle = angle;
car.direction = name;
return;
}
}
// Keine befahrbare Richtung gefunden - bewege zurück zur letzten Position
car.x = car.lastPosition.x;
car.y = car.lastPosition.y;
},
getPassengerTimeLeft(passenger) {
const now = Date.now();
const age = now - passenger.createdAt;
@@ -1354,7 +1823,7 @@ export default {
update() {
if (!this.gameRunning || this.isPaused) {
this.gameLoop = requestAnimationFrame(this.update);
// Game Loop komplett stoppen wenn pausiert - kein unnötiges requestAnimationFrame
return;
}
@@ -1364,14 +1833,25 @@ export default {
// Passagier-Generierung prüfen
this.updatePassengerGeneration();
// Autos-Generierung prüfen
this.updateCarGeneration();
// Autos aktualisieren
this.updateCars();
// Abgelaufene Passagiere entfernen
this.removeExpiredPassengers();
this.updateTaxi();
this.handlePassengerActions();
// Timer für geladene Passagiere aktualisieren (nach Passagier-Aktionen)
this.updatePassengerTimers();
// Timer für geladene Passagiere aktualisieren (nach Passagier-Aktionen) - gedrosselt
const nowTs = Date.now();
if (nowTs - this.lastPassengerTimerUpdate >= 200) { // Alle 200ms statt jeden Frame
this.updatePassengerTimers();
this.lastPassengerTimerUpdate = nowTs;
}
this.checkCollisions();
this.render();
@@ -1379,7 +1859,6 @@ export default {
this.checkRadarMeasurement();
// Minimap zeichnen (gedrosselt)
const nowTs = Date.now();
if (nowTs - this.lastMinimapDraw >= this.minimapDrawInterval) {
this.drawMinimap();
this.lastMinimapDraw = nowTs;
@@ -1775,6 +2254,13 @@ export default {
this.handleCrash();
}
});
// Prüfe Autos-Kollisionen
this.cars.forEach(car => {
if (this.checkCollision(this.taxi, car)) {
this.handleCrash('auto');
}
});
}
},
// Prüft Überfahren der (virtuell verdoppelten) Haltelinie aus der straßenzugewandten Seite
@@ -2004,6 +2490,24 @@ export default {
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) {
cancelAnimationFrame(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;
},
@@ -2055,11 +2559,31 @@ export default {
console.log('Nach Dialog-Close - isPaused:', this.isPaused, 'showPauseOverlay:', this.showPauseOverlay);
// Game Loop sicher neu starten (unabhängig vom vorherigen Zustand)
this.gameRunning = true;
try { cancelAnimationFrame(this.gameLoop); } catch (_) {}
this.gameLoop = 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) {
@@ -2511,10 +3035,24 @@ export default {
},
checkCollision(rect1, rect2) {
return rect1.x < rect2.x + rect2.width &&
rect1.x + rect1.width > rect2.x &&
rect1.y < rect2.y + rect2.height &&
rect1.y + rect1.height > rect2.y;
// Optionaler Puffer, um die Kollision weniger sensibel zu machen (sichtbarer Kontakt nötig)
// Für Car-vs-Taxi prüfen wir einen negativen Puffer (verkleinert Hitboxen)
const pad = (rect1.isCar || rect2.isCar) ? 4 : 0; // 4px Puffer pro Seite bei Autos (präziser Crash)
const aLeft = rect1.x + pad;
const aRight = rect1.x + rect1.width - pad;
const aTop = rect1.y + pad;
const aBottom = rect1.y + rect1.height - pad;
const bLeft = rect2.x + pad;
const bRight = rect2.x + rect2.width - pad;
const bTop = rect2.y + pad;
const bBottom = rect2.y + rect2.height - pad;
return aLeft < bRight &&
aRight > bLeft &&
aTop < bBottom &&
aBottom > bTop;
},
render() {
@@ -2541,6 +3079,8 @@ export default {
// Passagiere/Ziele werden aktuell nicht gezeichnet
// Zeichne Autos
this.drawCars();
// Zeichne Taxi
this.ctx.save();
@@ -2566,6 +3106,39 @@ export default {
this.ctx.restore();
},
// Zeichne alle Autos
drawCars() {
this.cars.forEach(car => {
this.ctx.save();
this.ctx.translate(car.x + car.width/2, car.y + car.height/2);
// Korrigiere die Rotation um 90° + weitere 180° - das Auto-Bild zeigt bereits in die richtige Richtung
this.ctx.rotate(car.angle + Math.PI / 2 + Math.PI);
if (this.carImage) {
// Zeichne Auto-Bild
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();
});
},
drawRoads() {
const tileSize = this.tiles.size; // 400x400px
@@ -2911,7 +3484,15 @@ export default {
},
async handleKeyDown(event) {
this.keys[event.key] = true;
// Browser-Shortcuts (F-Tasten, Strg/Meta+R) passieren lassen
const key = event.key;
const isFunctionKey = /^F\d{1,2}$/.test(key);
const isReloadShortcut = (event.ctrlKey || event.metaKey) && (key === 'r' || key === 'R');
if (isFunctionKey || isReloadShortcut) {
return; // nicht abfangen, Browser soll handeln
}
this.keys[key] = true;
// AudioContext bei erster Benutzerinteraktion initialisieren
this.ensureAudioUnlockedInEvent();
@@ -2962,7 +3543,13 @@ export default {
},
handleKeyUp(event) {
this.keys[event.key] = false;
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() {
@@ -3097,6 +3684,17 @@ export default {
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 {
@@ -3501,6 +4099,13 @@ export default {
// Spiel pausieren wenn Highscore angezeigt wird
if (!this.isPaused) {
this.isPaused = true;
// Game Loop stoppen
if (this.gameLoop) {
cancelAnimationFrame(this.gameLoop);
this.gameLoop = null;
}
// Motorgeräusch stoppen wenn pausiert
if (this.motorSound && this.motorSound.isPlaying) {
this.motorSound.stop();
@@ -3512,6 +4117,12 @@ export default {
// Highscore geschlossen - Spiel automatisch fortsetzen
this.isPaused = false;
this.showPauseOverlay = false;
// Game Loop neu starten
if (this.gameRunning && !this.gameLoop) {
this.gameLoop = requestAnimationFrame(this.update);
}
// Motor startet automatisch bei der nächsten Beschleunigung
}
},
@@ -3727,7 +4338,7 @@ export default {
.loaded-passengers-header {
background: #4caf50;
border-bottom: 1px solid #4caf50;
padding: 15px 20px;
padding: 5px 20px;
}
.loaded-passengers-title {
@@ -3738,7 +4349,7 @@ export default {
}
.loaded-passengers-content {
padding: 15px;
padding: 5px 20px;
max-height: 200px;
overflow-y: auto;
}
@@ -3842,7 +4453,7 @@ export default {
.waiting-passengers-header {
background: #f8f9fa;
border-bottom: 1px solid #ddd;
padding: 15px 20px;
padding: 5px 20px;
}
.waiting-passengers-title {
@@ -3853,7 +4464,7 @@ export default {
}
.waiting-passengers-list {
padding: 20px;
padding: 5px 20px;
max-height: 300px;
overflow-y: auto;
}
@@ -3862,7 +4473,7 @@ export default {
text-align: center;
color: #666;
font-style: italic;
padding: 20px 0;
padding: 5px 0;
}
.passenger-item {
@@ -3928,7 +4539,7 @@ export default {
.minimap-header {
background: #f8f9fa;
border-bottom: 1px solid #ddd;
padding: 15px 20px;
padding: 5px 20px;
display: flex;
justify-content: space-between;
align-items: center;