feat: add train driving system with 3D model and controls
Railtrack Pro Tests / Code Quality Check (pull_request) Waiting to run
Railtrack Pro Tests / Run Test Suite (pull_request) Failing after 5m44s
Railtrack Pro Tests / Code Coverage Check (pull_request) Has been cancelled

- Added Train and TrainController classes in train.js
- Created TrainRenderer for Three.js visualization
- Integrated train controls into game.js
- Updated index.html with train UI controls
- Added train control styles to css/styles.css
- Created test file for train module
- Train can accelerate, brake, reverse and stop
- Keyboard controls: W/↑ accelerate, S/↓ brake, R/D reverse, SPACE stop
This commit is contained in:
Railtrack Pro Dev
2026-03-13 14:48:50 +00:00
parent 2bf917f449
commit 47e1e64b8c
6 changed files with 1132 additions and 184 deletions
+444 -166
View File
@@ -1,228 +1,506 @@
/**
* Railtrack Pro - Game Logic
* Main game controller, UI interactions, and game state management
* @author Railtrack Pro Development Team
* Game Module
* Main game logic, UI interactions, and initialization
* Works in browser environment
* @module game
*/
/**
* Game class - main game controller
*/
class Game {
constructor() {
this.renderer = null;
this.world = null;
this.selectedTrackType = 'straight';
this.stats = { total: 0, straight: 0, curved: 0, junction: 0, signal: 0 };
this.init();
}
/**
* Initialize game
*/
init() {
console.log('[Game] Initializing Railtrack Pro...');
// Initialize renderer
// Initialize Three.js renderer
this.renderer = new Renderer();
// Initialize world
// Initialize world (track placement)
this.world = new World(this.renderer);
// Setup UI controls
this.setupUIControls();
// Initialize train system
this.trainController = new TrainController([]);
this.trainRenderer = new TrainRenderer(this.renderer.scene, { color: '#FF6B00' });
this.trainRenderer.setController(this.trainController);
this.createTrain();
// Initialize stats display
this.updateStats(0);
// Initialize controls
this.controls = new OrbitControls(this.renderer.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.maxPolarAngle = Math.PI / 2.2;
this.controls.minDistance = 5;
this.controls.maxDistance = 100;
// Input state
this.selectedTrackType = null;
this.keysPressed = {};
// Highlight first track type as active
this.setActiveTrackType('straight');
// Stats tracking
this.stats = {
totalTracks: 0,
trackCounts: {},
lastUpdate: Date.now()
};
console.log('[Game] Initialization complete');
// Initialize UI elements
this.initializeUI();
// Bind events
this.bindEvents();
// Start game loop
this.lastTime = Date.now();
this.animate();
console.log('🚂 Railtrack Pro initialized!');
}
/**
* Setup UI controls and event listeners
* Create a train for the world
*/
setupUIControls() {
const trackTypes = ['straight', 'curved', 'junction', 'signal'];
trackTypes.forEach(type => {
const button = document.getElementById(`btn-${type}`);
if (button) {
button.addEventListener('click', () => this.setActiveTrackType(type));
}
});
}
/**
* Set active track type for placement
*/
setActiveTrackType(type) {
this.selectedTrackType = type;
// Update button states
document.querySelectorAll('#controls button').forEach(btn => {
btn.classList.remove('active');
createTrain() {
const train = new Train({
id: 'main-train',
speed: 0,
maxSpeed: 5,
direction: 1,
color: '#FF6B00'
});
const activeButton = document.getElementById(`btn-${type}`);
if (activeButton) {
activeButton.classList.add('active');
}
console.log(`[Game] Selected track type: ${type}`);
this.train = train;
this.trainMesh = this.trainRenderer.createTrainMesh('main-train');
this.train.threeMesh = this.trainMesh;
}
/**
* Handle track click from renderer
* Initialize UI elements
*/
static onTrackClick(trackMesh) {
if (trackMesh.userData.piece) {
Game.removeTrack(trackMesh.userData.piece);
}
initializeUI() {
// Stats panel
this.statsPanel = document.getElementById('statsPanel');
this.trackStatsList = document.getElementById('trackStatsList');
// Train controls
this.trainControls = {
accelerateBtn: document.getElementById('accelerateTrain'),
brakeBtn: document.getElementById('brakeTrain'),
reverseBtn: document.getElementById('reverseTrain'),
stopBtn: document.getElementById('stopTrain'),
statusDiv: document.getElementById('trainStatus')
};
// Track type buttons
this.trackButtons = document.querySelectorAll('[data-track-type]');
// Info panel
this.infoPanel = document.getElementById('infoPanel');
// Update stats display
this.updateStatsDisplay();
this.updateTrainStatus();
}
/**
* Handle mouse click on viewport for track placement
* Bind event listeners
*/
static onViewportClick(event) {
// Only handle placement if a track type is selected
if (!Game.instance.selectedTrackType) {
console.log('[Game] No track type selected');
return;
}
bindEvents() {
// Track type selection
this.trackButtons.forEach(btn => {
btn.addEventListener('click', () => {
const trackType = btn.dataset.trackType;
this.selectTrackType(trackType, btn);
});
});
// Raycast for placement
const mouse = new THREE.Vector2();
const rect = Game.instance.renderer.renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Train control buttons
this.trainControls.accelerateBtn.addEventListener('click', () => this.accelerate());
this.trainControls.brakeBtn.addEventListener('click', () => this.brake());
this.trainControls.reverseBtn.addEventListener('click', () => this.reverse());
this.trainControls.stopBtn.addEventListener('click', () => this.stop());
Game.instance.renderer.raycaster.setFromCamera(mouse, Game.instance.renderer.camera);
// Create plane for intersection
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const target = new THREE.Vector3();
const intersectPoint = new THREE.Vector3();
Game.instance.renderer.raycaster.ray.intersectPlane(plane, intersectPoint);
if (intersectPoint) {
// Snap to grid
const snappedPosition = Game.instance.world.snapToGrid(intersectPoint);
// Keyboard controls
document.addEventListener('keydown', (e) => {
this.keysPressed[e.code] = true;
// Check if placement is valid
if (Game.instance.world.isValidPlacement(snappedPosition)) {
// Calculate rotation based on selected type
const rotation = new THREE.Vector3(0, Math.PI / 2, 0);
// Add track piece to world
Game.instance.world.addTrackPiece(
Game.instance.selectedTrackType,
snappedPosition,
rotation
);
// Train controls via keyboard
switch(e.code) {
case 'KeyW':
case 'ArrowUp':
this.accelerate();
break;
case 'KeyS':
case 'ArrowDown':
this.brake();
break;
case 'KeyR':
case 'KeyD':
this.reverse();
break;
case 'Space':
this.stop();
break;
}
});
document.addEventListener('keyup', (e) => {
this.keysPressed[e.code] = false;
});
// Mouse click for track placement/removal
this.renderer.domElement.addEventListener('click', (event) => {
this.handleMouseClick(event);
});
// Handle window resize
window.addEventListener('resize', () => {
this.onWindowResize();
});
}
/**
* Select track type for placement
* @param {string} trackType - Type of track to select
* @param {HTMLElement} button - Button element clicked
*/
selectTrackType(trackType, button) {
this.selectedTrackType = trackType;
// Update button styles
this.trackButtons.forEach(btn => {
btn.classList.remove('selected');
});
button.classList.add('selected');
console.log(`Selected track type: ${trackType}`);
}
/**
* Handle mouse click on canvas
* @param {MouseEvent} event - Mouse click event
*/
handleMouseClick(event) {
// Check if clicking on train first
const clickedMesh = event.target;
if (clickedMesh === this.trainMesh || clickedMesh.parent === this.trainMesh) {
// Train selected - show train info
this.showTrainInfo();
return;
}
// If no track type selected, show instruction
if (!this.selectedTrackType) {
this.showInfo('Select a track type to place! Click track buttons in the toolbar.');
return;
}
// Check if clicking on existing track (to remove)
const clickedTrack = this.checkClickedTrack(event);
if (clickedTrack) {
this.world.removeTrack(clickedTrack);
this.updateStatsDisplay();
this.showInfo(`Removed track at ${clickedTrack.position.x}, ${clickedTrack.position.y}`);
return;
}
// Check if clicking on canvas
if (this.world.isPlacementValid(event)) {
this.placeTrack(event);
} else {
this.showInfo('Click on the grid to place tracks. Click existing tracks to remove them.');
}
}
/**
* Check if mouse clicked on an existing track
* @param {MouseEvent} event - Mouse click event
* @returns {THREE.Object3D|null} Clicked track object or null
*/
checkClickedTrack(event) {
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, this.renderer.camera);
const intersects = raycaster.intersectObjects(this.world.allTracks, true);
if (intersects.length > 0) {
// Find parent group (the actual track piece)
let parent = intersects[0].object;
while (parent && !parent.userData.type) {
parent = parent.parent;
}
if (parent && parent.userData.type) {
return parent;
}
}
return null;
}
/**
* Place a track at the clicked location
* @param {MouseEvent} event - Mouse click event
*/
placeTrack(event) {
const gridPos = this.world.getGridPosition(event);
if (gridPos) {
const track = this.world.addTrack(
this.selectedTrackType,
gridPos.x,
gridPos.y,
this.renderer
);
if (track) {
this.stats.totalTracks++;
this.stats.trackCounts[this.selectedTrackType] = (this.stats.trackCounts[this.selectedTrackType] || 0) + 1;
this.updateStatsDisplay();
this.showInfo(`Placed ${this.selectedTrackType} at ${gridPos.x}, ${gridPos.y}`);
} else {
console.log('[Game] Placement invalid - invalid position');
this.showInfo(`Cannot place ${this.selectedTrackType} at that location.`, 'warning');
}
}
}
/**
* Remove track piece
* Accelerate the train (W/Up arrow)
*/
static removeTrack(trackPiece) {
if (Game.instance && Game.instance.world) {
Game.instance.world.removeTrackPiece(trackPiece);
accelerate() {
if (this.train) {
this.train.accelerate();
this.updateTrainStatus();
console.log('⚡ Train accelerating - Speed:', this.train.speed);
}
}
/**
* Update stats display in info panel
* Brake the train (S/Down arrow)
*/
static updateStats(count) {
if (!Game.instance) return;
const stats = Game.instance.world.getStats();
Game.instance.stats = stats;
const statsElement = document.getElementById('stats');
if (statsElement) {
statsElement.innerHTML = `
<h3>📊 Track Statistics</h3>
<p><strong>Total:</strong> ${stats.total}</p>
<p>⬇️ Straight: ${stats.straight}</p>
<p>⬇️ Curved: ${stats.curved}</p>
<p>⬇️ Junction: ${stats.junction}</p>
<p>⬇️ Signal: ${stats.signal}</p>
`;
brake() {
if (this.train) {
this.train.brake();
this.updateTrainStatus();
console.log('🛑 Train braking - Speed:', this.train.speed);
}
}
/**
* Update selected track info display
* Reverse train direction (R/D)
*/
static updateSelectedInfo(selectedMesh) {
if (!Game.instance) return;
const infoElement = document.getElementById('selected-info');
if (!selectedMesh) {
infoElement.innerHTML = `
<h4>🔍 Selection</h4>
<p>No track selected</p>
`;
return;
reverse() {
if (this.train) {
this.train.reverse();
this.updateTrainStatus();
console.log('🔄 Train reversed - Direction:', this.train.direction);
}
}
const track = selectedMesh.userData.piece;
if (!track) return;
/**
* Stop the train (Space)
*/
stop() {
if (this.train) {
this.train.stop();
this.updateTrainStatus();
console.log('🛑 Train stopped');
}
}
const config = TrackConfig.types[track.type] || { name: 'Unknown' };
infoElement.innerHTML = `
<h4>📦 Track Info</h4>
<p><strong>Type:</strong> ${config.name}</p>
<p><strong>Position:</strong> ${track.position.x.toFixed(1)}, ${track.position.z.toFixed(1)}</p>
<p><strong>Rotation:</strong> ${(track.rotation.y * 180 / Math.PI).toFixed(0)}°</p>
<p><strong>Cost:</strong> $${config.cost || 0}</p>
/**
* Show train information in info panel
*/
showTrainInfo() {
if (!this.train) return;
this.infoPanel.innerHTML = `
<h3>🚂 Train Information</h3>
<p><strong>ID:</strong> ${this.train.id}</p>
<p><strong>Speed:</strong> <latex>${Math.abs(this.train.speed).toFixed(1)} units/s</latex></p>
<p><strong>Direction:</strong> ${this.train.direction > 0 ? '➡️ Forward' : '⬅️ Reverse'}</p>
<p><strong>Status:</strong> ${this.train.isMoving ? '🚂 Moving' : '🛑 Stopped'}</p>
<p><strong>Keyboard:</strong> W/↑ = Accelerate, S/↓ = Brake, R/D = Reverse, SPACE = Stop</p>
`;
}
/**
* Get current game instance
* Show information message in info panel
* @param {string} message - Message to display
* @param {string} type - Type (info, warning, error)
*/
static getInstance() {
return this.instance;
showInfo(message, type = 'info') {
this.infoPanel.innerHTML = `
<h3>${type === 'warning' ? '⚠️' : type === 'error' ? '❌' : '️'} ${type.toUpperCase()}</h3>
<p>${message}</p>
`;
// Clear info after 3 seconds
setTimeout(() => {
if (this.infoPanel && this.infoPanel.innerHTML.includes(message)) {
this.infoPanel.innerHTML = '<p>Click tracks to place/remove. Select a track type to start building!</p>';
}
}, 3000);
}
/**
* Initialize game on DOM ready
* Update stats display in UI
*/
initGame() {
Game.instance = new Game();
// Add click handler to viewport
const viewport = document.getElementById('viewport');
if (viewport) {
viewport.addEventListener('click', (event) => {
// Prevent placement if clicking on info panel
if (event.target.closest('#info-panel')) return;
Game.onViewportClick(event);
});
updateStatsDisplay() {
if (!this.statsPanel) return;
let statsHTML = `
<h3>📊 Statistics</h3>
<p><strong>Total Tracks:</strong> ${this.stats.totalTracks}</p>
`;
if (this.stats.trackCounts && Object.keys(this.stats.trackCounts).length > 0) {
statsHTML += `
<h4>Track Types:</h4>
<ul id="trackStatsList">
`;
for (const [type, count] of Object.entries(this.stats.trackCounts)) {
statsHTML += `<li>${type}: <strong>${count}</strong></li>`;
}
statsHTML += `</ul>`;
}
console.log('[Game] Game instance created and ready');
this.statsPanel.innerHTML = statsHTML;
}
/**
* Update train status display in UI
*/
updateTrainStatus() {
if (!this.trainControls.statusDiv || !this.train) return;
const status = this.train.isMoving ? '🚂 Moving' : '🛑 Stopped';
const direction = this.train.direction > 0 ? '➡️ Forward' : '⬅️ Reverse';
this.trainControls.statusDiv.innerHTML = `
<p><strong>Status:</strong> ${status}</p>
<p><strong>Direction:</strong> ${direction}</p>
<p><strong>Speed:</strong> <latex>${Math.abs(this.train.speed).toFixed(1)} units/s</latex></p>
`;
}
/**
* Handle window resize
*/
onWindowResize() {
this.renderer.onWindowResize();
}
/**
* Update game state based on input
* @param {number} deltaTime - Time delta in seconds
*/
update(deltaTime) {
// Update train controls based on keyboard
if (this.train) {
// Keyboard controls with smoothing
if (this.keysPressed['KeyW'] || this.keysPressed['ArrowUp']) {
this.accelerate();
}
if (this.keysPressed['KeyS'] || this.keysPressed['ArrowDown']) {
this.brake();
}
if (this.keysPressed['KeyR'] || this.keysPressed['KeyD']) {
this.reverse();
}
// Update train physics
this.train.update(deltaTime);
this.updateTrainStatus();
// Update train controller if we have a track path
if (this.trainController.trackPath.length > 0) {
this.trainController.update(deltaTime, this.train.speed);
// Get current position and update train mesh
const currentPosition = this.trainController.getCurrentPosition();
const rotation = this.getTrainRotation();
this.trainRenderer.updateTrain('main-train', currentPosition, rotation);
}
}
// Update controller
this.controls.update();
}
/**
* Calculate train rotation based on track direction
* @returns {number} Rotation angle in radians
*/
getTrainRotation() {
let rotation = 0;
if (this.trainController && this.trainController.trackPath.length >= 2) {
const totalLength = this.trainController.totalPathLength;
const targetDistance = this.trainController.distance;
let distance = 0;
for (let i = 0; i < this.trainController.trackPath.length - 1; i++) {
const p1 = this.trainController.trackPath[i];
const p2 = this.trainController.trackPath[i + 1];
const dist = this._distanceBetween(p1, p2);
if (targetDistance >= distance && targetDistance <= distance + dist) {
const x1 = p1.x, y1 = p1.y;
const x2 = p2.x, y2 = p2.y;
rotation = Math.atan2(y2 - y1, x2 - x1);
// Adjust for train direction
if (this.train && this.train.direction < 0) {
rotation += Math.PI;
}
break;
}
distance += dist;
}
}
return rotation;
}
/**
* Calculate distance between two points
* @param {Object} p1 - First point {x, y, z}
* @param {Object} p2 - Second point {x, y, z}
* @returns {number} Distance
* @private
*/
_distanceBetween(p1, p2) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dz = p2.z - p1.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* Animation loop - main game loop
*/
animate() {
requestAnimationFrame(() => this.animate());
const now = Date.now();
const deltaTime = (now - this.lastTime) / 1000;
this.lastTime = now;
this.update(deltaTime);
this.renderer.render();
}
/**
* Set track path for train
* @param {Array} path - Array of {x, y, z} points
*/
setTrainPath(path) {
this.trainController.setTrackPath(path);
}
/**
* Dispose game resources
*/
dispose() {
this.trainRenderer.dispose();
this.world.dispose();
this.renderer.dispose();
window.removeEventListener('resize', this.onWindowResize);
}
}
// Global game instance
Game.instance = null;
// Initialize game when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
Game.initGame();
});
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = Game;
}