Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 142da81b01 | |||
| 0c168ef543 | |||
| 3f7237c106 | |||
| 7191519b95 | |||
| 47e1e64b8c |
Binary file not shown.
Binary file not shown.
@@ -25,7 +25,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -71,7 +70,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -97,7 +95,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -119,3 +119,87 @@ button.active {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Train Controls Section === */
|
||||||
|
#trainControls {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#trainControls h3 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.train-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--secondary-color) 0%, var(--secondary-color-dark) 100%);
|
||||||
|
border: 2px solid var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.train-btn:hover {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color-dark) 100%);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.train-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 6px rgba(255, 107, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#trainStatus {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#trainStatus p {
|
||||||
|
margin: 5px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#trainStatus strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Train animation indicator */
|
||||||
|
.train-moving-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #00ff00;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Train status colors */
|
||||||
|
.status-moving { color: #00ff00; }
|
||||||
|
.status-stopped { color: #ff0000; }
|
||||||
|
.status-reversing { color: #ff8800; }
|
||||||
|
|||||||
+63
-18
@@ -5,29 +5,74 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Railtrack Pro</title>
|
<title>Railtrack Pro</title>
|
||||||
<link rel="stylesheet" href="css/styles.css">
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
|
||||||
|
<script src="js/renderer.js"></script>
|
||||||
|
<script src="js/world.js"></script>
|
||||||
|
<script src="js/tracks.js"></script>
|
||||||
|
<script src="js/train.js"></script>
|
||||||
|
<script src="js/trainRenderer.js"></script>
|
||||||
|
<script src="js/game.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize game when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const game = new Game();
|
||||||
|
console.log('🚂 Railtrack Pro initialized!');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<div id="topBar">
|
||||||
<h1>🚂 Railtrack Pro</h1>
|
<h1>🚂 Railtrack Pro</h1>
|
||||||
<nav id="controls">
|
</div>
|
||||||
<button id="btn-straight">Straight</button>
|
|
||||||
<button id="btn-curved">Curved</button>
|
<div id="mainContainer">
|
||||||
<button id="btn-junction">Junction</button>
|
<div id="leftPanel">
|
||||||
<button id="btn-signal">Signal</button>
|
<div id="trackControls">
|
||||||
</nav>
|
<h3>Select Track Type</h3>
|
||||||
</header>
|
<button data-track-type="straight" class="track-btn">Straight</button>
|
||||||
<div id="viewport"></div>
|
<button data-track-type="curve" class="track-btn">Curve</button>
|
||||||
<div id="info-panel">
|
<button data-track-type="junction" class="track-btn">Junction</button>
|
||||||
<div id="stats"></div>
|
<button data-track-type="signal" class="track-btn">Signal</button>
|
||||||
<div id="selected-info"></div>
|
</div>
|
||||||
|
|
||||||
|
<div id="gameControls">
|
||||||
|
<h3>Track Controls</h3>
|
||||||
|
<button id="removeMode" class="control-btn">🗑️ Remove Mode</button>
|
||||||
|
<button id="resetCamera" class="control-btn">📷 Reset Camera</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="trainControls">
|
||||||
|
<h3>🚂 Train Controls</h3>
|
||||||
|
<button id="accelerateTrain" class="train-btn">⬆️ Accelerate</button>
|
||||||
|
<button id="brakeTrain" class="train-btn">⬇️ Brake</button>
|
||||||
|
<button id="reverseTrain" class="train-btn">↻ Reverse</button>
|
||||||
|
<button id="stopTrain" class="train-btn">⏹️ Stop</button>
|
||||||
|
<div id="trainStatus">
|
||||||
|
<p><strong>Status:</strong> Stopped</p>
|
||||||
|
<p><strong>Speed:</strong> 0 units/s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="statsPanel">
|
||||||
|
<h3>Statistics</h3>
|
||||||
|
<p><strong>Total Tracks:</strong> 0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewportContainer">
|
||||||
|
<div id="viewport">
|
||||||
|
<div id="gameCanvas"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rightPanel">
|
||||||
|
<div id="infoPanel">
|
||||||
|
<p>Click tracks to place/remove. Select a track type to start building!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.156.0/build/three.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.156.0/examples/js/controls/OrbitControls.js"></script>
|
|
||||||
<script src="js/renderer.js"></script>
|
|
||||||
<script src="js/tracks.js"></script>
|
|
||||||
<script src="js/world.js"></script>
|
|
||||||
<script src="js/game.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/test/**/*.test.js'],
|
||||||
|
verbose: true,
|
||||||
|
clearMocks: true,
|
||||||
|
resetModules: true,
|
||||||
|
coverageReporters: ['text', 'lcov', 'html'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'js/**/*.js',
|
||||||
|
'!js/**/__tests__/**',
|
||||||
|
'!js/**/*.test.js'
|
||||||
|
]
|
||||||
|
};
|
||||||
+434
-156
@@ -1,228 +1,506 @@
|
|||||||
/**
|
/**
|
||||||
* Railtrack Pro - Game Logic
|
* Game Module
|
||||||
* Main game controller, UI interactions, and game state management
|
* Main game logic, UI interactions, and initialization
|
||||||
* @author Railtrack Pro Development Team
|
* Works in browser environment
|
||||||
|
* @module game
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game class - main game controller
|
||||||
|
*/
|
||||||
class Game {
|
class Game {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.renderer = null;
|
// Initialize Three.js renderer
|
||||||
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
|
|
||||||
this.renderer = new Renderer();
|
this.renderer = new Renderer();
|
||||||
|
|
||||||
// Initialize world
|
// Initialize world (track placement)
|
||||||
this.world = new World(this.renderer);
|
this.world = new World(this.renderer);
|
||||||
|
|
||||||
// Setup UI controls
|
// Initialize train system
|
||||||
this.setupUIControls();
|
this.trainController = new TrainController([]);
|
||||||
|
this.trainRenderer = new TrainRenderer(this.renderer.scene, { color: '#FF6B00' });
|
||||||
|
this.trainRenderer.setController(this.trainController);
|
||||||
|
this.createTrain();
|
||||||
|
|
||||||
// Initialize stats display
|
// Initialize controls
|
||||||
this.updateStats(0);
|
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;
|
||||||
|
|
||||||
// Highlight first track type as active
|
// Input state
|
||||||
this.setActiveTrackType('straight');
|
this.selectedTrackType = null;
|
||||||
|
this.keysPressed = {};
|
||||||
|
|
||||||
console.log('[Game] Initialization complete');
|
// Stats tracking
|
||||||
|
this.stats = {
|
||||||
|
totalTracks: 0,
|
||||||
|
trackCounts: {},
|
||||||
|
lastUpdate: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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() {
|
createTrain() {
|
||||||
const trackTypes = ['straight', 'curved', 'junction', 'signal'];
|
const train = new Train({
|
||||||
|
id: 'main-train',
|
||||||
|
speed: 0,
|
||||||
|
maxSpeed: 5,
|
||||||
|
direction: 1,
|
||||||
|
color: '#FF6B00'
|
||||||
|
});
|
||||||
|
|
||||||
trackTypes.forEach(type => {
|
this.train = train;
|
||||||
const button = document.getElementById(`btn-${type}`);
|
this.trainMesh = this.trainRenderer.createTrainMesh('main-train');
|
||||||
if (button) {
|
this.train.threeMesh = this.trainMesh;
|
||||||
button.addEventListener('click', () => this.setActiveTrackType(type));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize UI elements
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event listeners
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
// Track type selection
|
||||||
|
this.trackButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const trackType = btn.dataset.trackType;
|
||||||
|
this.selectTrackType(trackType, btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
|
||||||
|
// Keyboard controls
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
this.keysPressed[e.code] = true;
|
||||||
|
|
||||||
|
// 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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set active track type for placement
|
* Select track type for placement
|
||||||
|
* @param {string} trackType - Type of track to select
|
||||||
|
* @param {HTMLElement} button - Button element clicked
|
||||||
*/
|
*/
|
||||||
setActiveTrackType(type) {
|
selectTrackType(trackType, button) {
|
||||||
this.selectedTrackType = type;
|
this.selectedTrackType = trackType;
|
||||||
|
|
||||||
// Update button states
|
// Update button styles
|
||||||
document.querySelectorAll('#controls button').forEach(btn => {
|
this.trackButtons.forEach(btn => {
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('selected');
|
||||||
});
|
});
|
||||||
|
button.classList.add('selected');
|
||||||
|
|
||||||
const activeButton = document.getElementById(`btn-${type}`);
|
console.log(`Selected track type: ${trackType}`);
|
||||||
if (activeButton) {
|
|
||||||
activeButton.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Game] Selected track type: ${type}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle track click from renderer
|
* Handle mouse click on canvas
|
||||||
|
* @param {MouseEvent} event - Mouse click event
|
||||||
*/
|
*/
|
||||||
static onTrackClick(trackMesh) {
|
handleMouseClick(event) {
|
||||||
if (trackMesh.userData.piece) {
|
// Check if clicking on train first
|
||||||
Game.removeTrack(trackMesh.userData.piece);
|
const clickedMesh = event.target;
|
||||||
}
|
if (clickedMesh === this.trainMesh || clickedMesh.parent === this.trainMesh) {
|
||||||
}
|
// Train selected - show train info
|
||||||
|
this.showTrainInfo();
|
||||||
/**
|
|
||||||
* Handle mouse click on viewport for track placement
|
|
||||||
*/
|
|
||||||
static onViewportClick(event) {
|
|
||||||
// Only handle placement if a track type is selected
|
|
||||||
if (!Game.instance.selectedTrackType) {
|
|
||||||
console.log('[Game] No track type selected');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raycast for placement
|
// If no track type selected, show instruction
|
||||||
const mouse = new THREE.Vector2();
|
if (!this.selectedTrackType) {
|
||||||
const rect = Game.instance.renderer.renderer.domElement.getBoundingClientRect();
|
this.showInfo('Select a track type to place! Click track buttons in the toolbar.');
|
||||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
return;
|
||||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
}
|
||||||
|
|
||||||
Game.instance.renderer.raycaster.setFromCamera(mouse, Game.instance.renderer.camera);
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Create plane for intersection
|
// Check if clicking on canvas
|
||||||
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
if (this.world.isPlacementValid(event)) {
|
||||||
const target = new THREE.Vector3();
|
this.placeTrack(event);
|
||||||
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);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[Game] Placement invalid - invalid position');
|
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 {
|
||||||
|
this.showInfo(`Cannot place ${this.selectedTrackType} at that location.`, 'warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove track piece
|
* Accelerate the train (W/Up arrow)
|
||||||
*/
|
*/
|
||||||
static removeTrack(trackPiece) {
|
accelerate() {
|
||||||
if (Game.instance && Game.instance.world) {
|
if (this.train) {
|
||||||
Game.instance.world.removeTrackPiece(trackPiece);
|
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) {
|
brake() {
|
||||||
if (!Game.instance) return;
|
if (this.train) {
|
||||||
|
this.train.brake();
|
||||||
const stats = Game.instance.world.getStats();
|
this.updateTrainStatus();
|
||||||
Game.instance.stats = stats;
|
console.log('🛑 Train braking - Speed:', this.train.speed);
|
||||||
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update selected track info display
|
* Reverse train direction (R/D)
|
||||||
*/
|
*/
|
||||||
static updateSelectedInfo(selectedMesh) {
|
reverse() {
|
||||||
if (!Game.instance) return;
|
if (this.train) {
|
||||||
|
this.train.reverse();
|
||||||
const infoElement = document.getElementById('selected-info');
|
this.updateTrainStatus();
|
||||||
|
console.log('🔄 Train reversed - Direction:', this.train.direction);
|
||||||
if (!selectedMesh) {
|
}
|
||||||
infoElement.innerHTML = `
|
|
||||||
<h4>🔍 Selection</h4>
|
|
||||||
<p>No track selected</p>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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' };
|
/**
|
||||||
|
* Show train information in info panel
|
||||||
|
*/
|
||||||
|
showTrainInfo() {
|
||||||
|
if (!this.train) return;
|
||||||
|
|
||||||
infoElement.innerHTML = `
|
this.infoPanel.innerHTML = `
|
||||||
<h4>📦 Track Info</h4>
|
<h3>🚂 Train Information</h3>
|
||||||
<p><strong>Type:</strong> ${config.name}</p>
|
<p><strong>ID:</strong> ${this.train.id}</p>
|
||||||
<p><strong>Position:</strong> ${track.position.x.toFixed(1)}, ${track.position.z.toFixed(1)}</p>
|
<p><strong>Speed:</strong> <latex>${Math.abs(this.train.speed).toFixed(1)} units/s</latex></p>
|
||||||
<p><strong>Rotation:</strong> ${(track.rotation.y * 180 / Math.PI).toFixed(0)}°</p>
|
<p><strong>Direction:</strong> ${this.train.direction > 0 ? '➡️ Forward' : '⬅️ Reverse'}</p>
|
||||||
<p><strong>Cost:</strong> $${config.cost || 0}</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() {
|
showInfo(message, type = 'info') {
|
||||||
return this.instance;
|
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() {
|
updateStatsDisplay() {
|
||||||
Game.instance = new Game();
|
if (!this.statsPanel) return;
|
||||||
|
|
||||||
// Add click handler to viewport
|
let statsHTML = `
|
||||||
const viewport = document.getElementById('viewport');
|
<h3>📊 Statistics</h3>
|
||||||
if (viewport) {
|
<p><strong>Total Tracks:</strong> ${this.stats.totalTracks}</p>
|
||||||
viewport.addEventListener('click', (event) => {
|
`;
|
||||||
// Prevent placement if clicking on info panel
|
|
||||||
if (event.target.closest('#info-panel')) return;
|
if (this.stats.trackCounts && Object.keys(this.stats.trackCounts).length > 0) {
|
||||||
Game.onViewportClick(event);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global game instance
|
// Update controller
|
||||||
Game.instance = null;
|
this.controls.update();
|
||||||
|
}
|
||||||
// Initialize game when DOM is ready
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
/**
|
||||||
Game.initGame();
|
* Calculate train rotation based on track direction
|
||||||
});
|
* @returns {number} Rotation angle in radians
|
||||||
|
*/
|
||||||
// Export for module usage
|
getTrainRotation() {
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
let rotation = 0;
|
||||||
module.exports = Game;
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Train Module
|
||||||
|
* Handles train model and controller for driving along tracks
|
||||||
|
* Works in browser environment with Three.js global
|
||||||
|
* @module train
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Train class - represents a train that can move along tracks
|
||||||
|
*/
|
||||||
|
class Train {
|
||||||
|
/**
|
||||||
|
* Create a train instance
|
||||||
|
* @param {Object} options - Train configuration options
|
||||||
|
* @param {string} options.id - Unique identifier for the train
|
||||||
|
* @param {number} options.speed - Initial speed
|
||||||
|
* @param {number} options.maxSpeed - Maximum speed
|
||||||
|
* @param {number} options.direction - Direction (1 or -1)
|
||||||
|
* @param {number} options.length - Train length for display
|
||||||
|
* @param {string} options.color - Train body color
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.id = options.id || `train-${Date.now()}`;
|
||||||
|
this.speed = options.speed || 0;
|
||||||
|
this.maxSpeed = options.maxSpeed || 5;
|
||||||
|
this.acceleration = options.acceleration || 0.5;
|
||||||
|
this.brakeDeceleration = options.brakeDeceleration || 0.3;
|
||||||
|
this.direction = options.direction || 1;
|
||||||
|
this.length = options.length || 3;
|
||||||
|
|
||||||
|
this.isMoving = false;
|
||||||
|
this.threeMesh = null;
|
||||||
|
this.color = options.color || '#FF6B00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accelerate the train
|
||||||
|
*/
|
||||||
|
accelerate() {
|
||||||
|
this.speed = Math.min(this.speed + this.acceleration * this.direction, this.maxSpeed * this.direction);
|
||||||
|
this.isMoving = this.speed !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brake the train
|
||||||
|
*/
|
||||||
|
brake() {
|
||||||
|
const brakeAmount = this.brakeDeceleration * this.direction;
|
||||||
|
const newSpeed = this.speed - brakeAmount;
|
||||||
|
|
||||||
|
if (Math.abs(newSpeed) < Math.abs(this.brakeDeceleration)) {
|
||||||
|
this.speed = 0;
|
||||||
|
this.isMoving = false;
|
||||||
|
} else {
|
||||||
|
this.speed = newSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse direction
|
||||||
|
*/
|
||||||
|
reverse() {
|
||||||
|
this.direction *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the train
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
this.speed = 0;
|
||||||
|
this.isMoving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update train physics based on speed
|
||||||
|
* @param {number} deltaTime - Time delta in seconds
|
||||||
|
*/
|
||||||
|
update(deltaTime) {
|
||||||
|
if (this.isMoving) {
|
||||||
|
this.speed = this.speed > 0 ? Math.max(this.speed - 0.1 * this.direction, 0) : Math.min(this.speed + 0.1 * this.direction, 0);
|
||||||
|
this.isMoving = this.speed !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get train velocity
|
||||||
|
* @returns {number} Train velocity
|
||||||
|
*/
|
||||||
|
getVelocity() {
|
||||||
|
return this.speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrainController class - manages train position along track path
|
||||||
|
*/
|
||||||
|
class TrainController {
|
||||||
|
/**
|
||||||
|
* Create a train controller
|
||||||
|
* @param {Array} trackPath - Array of {x, y, z} points defining the track path
|
||||||
|
*/
|
||||||
|
constructor(trackPath = []) {
|
||||||
|
this.trackPath = trackPath || [];
|
||||||
|
this.progress = 0; // 0.0 to 1.0 along the path
|
||||||
|
this.distance = 0; // Distance traveled along path
|
||||||
|
this.currentPosition = { x: 0, y: 0, z: 0 };
|
||||||
|
this.totalPathLength = this._calculatePathLength();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total path length
|
||||||
|
* @returns {number} Total length of the track path
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_calculatePathLength() {
|
||||||
|
if (this.trackPath.length < 2) return 0;
|
||||||
|
|
||||||
|
let totalLength = 0;
|
||||||
|
for (let i = 1; i < this.trackPath.length; i++) {
|
||||||
|
const p1 = this.trackPath[i - 1];
|
||||||
|
const p2 = this.trackPath[i];
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const dz = p2.z - p1.z;
|
||||||
|
totalLength += Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
}
|
||||||
|
return totalLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set distance traveled along the path
|
||||||
|
* @param {number} distance - Distance to set
|
||||||
|
*/
|
||||||
|
setDistance(distance) {
|
||||||
|
this.distance = Math.max(0, Math.min(distance, this.totalPathLength));
|
||||||
|
this.progress = this.totalPathLength > 0 ? this.distance / this.totalPathLength : 0;
|
||||||
|
this.updateCurrentPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current position on the track path
|
||||||
|
* @returns {Object} Position {x, y, z}
|
||||||
|
*/
|
||||||
|
getCurrentPosition() {
|
||||||
|
return this.currentPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update current position based on progress
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
updateCurrentPosition() {
|
||||||
|
if (this.trackPath.length < 2) {
|
||||||
|
this.currentPosition = this.trackPath[0] || { x: 0, y: 0, z: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLength = this.totalPathLength;
|
||||||
|
const targetDistance = this.progress * totalLength;
|
||||||
|
|
||||||
|
let distance = 0;
|
||||||
|
for (let i = 0; i < this.trackPath.length - 1; i++) {
|
||||||
|
const p1 = this.trackPath[i];
|
||||||
|
const p2 = this.trackPath[i + 1];
|
||||||
|
const p1Length = distance;
|
||||||
|
const p2Length = distance + this._distanceBetween(p1, p2);
|
||||||
|
|
||||||
|
if (targetDistance >= p1Length && targetDistance <= p2Length) {
|
||||||
|
const segmentProgress = (targetDistance - p1Length) / (p2Length - p1Length);
|
||||||
|
this.currentPosition = {
|
||||||
|
x: p1.x + (p2.x - p1.x) * segmentProgress,
|
||||||
|
y: p1.y + (p2.y - p1.y) * segmentProgress,
|
||||||
|
z: p1.z + (p2.z - p1.z) * segmentProgress
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
distance += this._distanceBetween(p1, p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of path
|
||||||
|
this.currentPosition = this.trackPath[this.trackPath.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update controller based on train speed
|
||||||
|
* @param {number} deltaTime - Time delta in seconds
|
||||||
|
* @param {number} speed - Train speed
|
||||||
|
*/
|
||||||
|
update(deltaTime, speed) {
|
||||||
|
const distanceDelta = speed * deltaTime;
|
||||||
|
this.setDistance(this.distance + distanceDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set track path
|
||||||
|
* @param {Array} path - Array of {x, y, z} points
|
||||||
|
*/
|
||||||
|
setTrackPath(path) {
|
||||||
|
this.trackPath = path;
|
||||||
|
this.totalPathLength = this._calculatePathLength();
|
||||||
|
this.progress = 0;
|
||||||
|
this.distance = 0;
|
||||||
|
this.updateCurrentPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for browser global scope
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.Train = Train;
|
||||||
|
window.TrainController = TrainController;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for Node.js/Jest
|
||||||
|
module.exports = { Train, TrainController };
|
||||||
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Train Renderer
|
||||||
|
* Handles Three.js visualization of train models
|
||||||
|
* Works in browser environment with Three.js global
|
||||||
|
* @module trainRenderer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrainRenderer class - manages 3D train visualization
|
||||||
|
*/
|
||||||
|
class TrainRenderer {
|
||||||
|
/**
|
||||||
|
* Create a train renderer
|
||||||
|
* @param {THREE.Scene} scene - Three.js scene
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {string} options.color - Train body color hex
|
||||||
|
*/
|
||||||
|
constructor(scene, options = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.trainMeshes = new Map();
|
||||||
|
this.trainController = null;
|
||||||
|
|
||||||
|
// Default train colors
|
||||||
|
this.colors = {
|
||||||
|
body: new THREE.Color(options.color || '#FF6B00'),
|
||||||
|
windows: new THREE.Color('#87CEEB'),
|
||||||
|
wheels: new THREE.Color('#333333'),
|
||||||
|
details: new THREE.Color('#FFFFFF')
|
||||||
|
};
|
||||||
|
|
||||||
|
this.trains = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a 3D train mesh
|
||||||
|
* @param {string} trainId - Unique train identifier
|
||||||
|
* @returns {THREE.Group} Train mesh group
|
||||||
|
*/
|
||||||
|
createTrainMesh(trainId) {
|
||||||
|
const trainGroup = new THREE.Group();
|
||||||
|
trainGroup.userData.id = trainId;
|
||||||
|
|
||||||
|
// Train body (main chassis)
|
||||||
|
const bodyGeometry = new THREE.BoxGeometry(2, 1.2, 4);
|
||||||
|
const bodyMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color: this.colors.body,
|
||||||
|
shininess: 50
|
||||||
|
});
|
||||||
|
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
|
||||||
|
body.position.y = 0.6;
|
||||||
|
trainGroup.add(body);
|
||||||
|
|
||||||
|
// Cab area (front)
|
||||||
|
const cabGeometry = new THREE.BoxGeometry(1.8, 0.8, 1);
|
||||||
|
const cabMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color: this.colors.windows,
|
||||||
|
shininess: 30
|
||||||
|
});
|
||||||
|
const cab = new THREE.Mesh(cabGeometry, cabMaterial);
|
||||||
|
cab.position.set(0.9, 1.1, 0);
|
||||||
|
cab.rotation.y = Math.PI / 4;
|
||||||
|
trainGroup.add(cab);
|
||||||
|
|
||||||
|
// Wheels (4 visible wheels)
|
||||||
|
const wheelGeometry = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 16);
|
||||||
|
const wheelMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color: this.colors.wheels,
|
||||||
|
shininess: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
const wheelPositions = [
|
||||||
|
{ x: 0.3, y: -0.3, z: 1.2, rotZ: Math.PI / 2 },
|
||||||
|
{ x: 0.3, y: -0.3, z: -1.2, rotZ: Math.PI / 2 },
|
||||||
|
{ x: -0.3, y: -0.3, z: 1.2, rotZ: Math.PI / 2 },
|
||||||
|
{ x: -0.3, y: -0.3, z: -1.2, rotZ: Math.PI / 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
wheelPositions.forEach(pos => {
|
||||||
|
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
|
||||||
|
wheel.position.set(pos.x, pos.y, pos.z);
|
||||||
|
wheel.rotation.z = pos.rotZ;
|
||||||
|
trainGroup.add(wheel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Train front marker (red light)
|
||||||
|
const lightGeometry = new THREE.SphereGeometry(0.15);
|
||||||
|
const lightMaterial = new THREE.MeshBasicMaterial({ color: 0xFF0000 });
|
||||||
|
const frontLight = new THREE.Mesh(lightGeometry, lightMaterial);
|
||||||
|
frontLight.position.set(1.01, 1.0, 0);
|
||||||
|
trainGroup.add(frontLight);
|
||||||
|
|
||||||
|
// Train rear marker (red tail light)
|
||||||
|
const rearLight = new THREE.Mesh(lightGeometry, lightMaterial);
|
||||||
|
rearLight.position.set(-1.01, 1.0, 0);
|
||||||
|
trainGroup.add(rearLight);
|
||||||
|
|
||||||
|
// Add train to scene
|
||||||
|
this.scene.add(trainGroup);
|
||||||
|
this.trainMeshes.set(trainId, trainGroup);
|
||||||
|
|
||||||
|
return trainGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a train from the scene
|
||||||
|
* @param {string} trainId - Train identifier to remove
|
||||||
|
*/
|
||||||
|
removeTrain(trainId) {
|
||||||
|
const mesh = this.trainMeshes.get(trainId);
|
||||||
|
if (mesh) {
|
||||||
|
this.scene.remove(mesh);
|
||||||
|
mesh.traverse(child => {
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach(mat => mat.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.trainMeshes.delete(trainId);
|
||||||
|
delete this.trains[trainId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update train mesh position and rotation based on train state
|
||||||
|
* @param {string} trainId - Train identifier
|
||||||
|
* @param {Object} position - Position {x, y, z}
|
||||||
|
* @param {number} rotation - Rotation angle in radians
|
||||||
|
*/
|
||||||
|
updateTrain(trainId, position, rotation) {
|
||||||
|
const mesh = this.trainMeshes.get(trainId);
|
||||||
|
if (mesh) {
|
||||||
|
mesh.position.set(position.x, position.y, position.z);
|
||||||
|
mesh.rotation.y = rotation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all train meshes based on train controller progress
|
||||||
|
* @param {number} deltaTime - Time delta in seconds
|
||||||
|
* @param {number} speed - Train speed
|
||||||
|
*/
|
||||||
|
updateAll(deltaTime, speed) {
|
||||||
|
const progress = this.trainController ? this.trainController.progress : 0;
|
||||||
|
const currentPos = this.trainController ? this.trainController.currentPosition : { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
|
// Calculate orientation based on direction
|
||||||
|
let rotation = 0;
|
||||||
|
if (this.trainController && this.trainController.trackPath.length >= 2) {
|
||||||
|
const totalLength = this.trainController.totalPathLength;
|
||||||
|
const targetDistance = progress * totalLength;
|
||||||
|
|
||||||
|
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 segmentProgress = (targetDistance - distance) / dist;
|
||||||
|
const x1 = p1.x, y1 = p1.y;
|
||||||
|
const x2 = p2.x, y2 = p2.y;
|
||||||
|
rotation = Math.atan2(y2 - y1, x2 - x1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
distance += dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mesh positions
|
||||||
|
this.trainMeshes.forEach((mesh, trainId) => {
|
||||||
|
this.updateTrain(trainId, currentPos, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set train controller reference
|
||||||
|
* @param {TrainController} controller - TrainController instance
|
||||||
|
*/
|
||||||
|
setController(controller) {
|
||||||
|
this.trainController = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all train meshes
|
||||||
|
* @returns {Map} Map of trainId to mesh
|
||||||
|
*/
|
||||||
|
getTrainMeshes() {
|
||||||
|
return this.trainMeshes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose resources
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
this.trainMeshes.forEach(mesh => {
|
||||||
|
mesh.traverse(child => {
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach(mat => mat.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.trainMeshes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for browser global scope
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.TrainRenderer = TrainRenderer;
|
||||||
|
}
|
||||||
Generated
+986
File diff suppressed because it is too large
Load Diff
+12
-5
@@ -1,20 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "railtrack_pro",
|
"name": "railtrack_pro",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "**A web-based railway construction game featuring track building, junctions, signals, and 3D driver's eye view.**",
|
"description": "A web-based railway construction game featuring track building, junctions, signals, and 3D driver's eye view.",
|
||||||
"main": "index.js",
|
|
||||||
"directories": {
|
"directories": {
|
||||||
"test": "test"
|
"test": "test"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"start": "python3 -m http.server 8080",
|
||||||
|
"test": "npm run test:unit",
|
||||||
|
"test:unit": "jest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:all": "npm run test:unit && npm run test:e2e",
|
||||||
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:report": "jest --coverage --coverageReporters=html"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": ["game", "railway", "threejs"],
|
||||||
"author": "",
|
"author": "Railtrack Pro Team",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"jest": "^30.3.0",
|
"jest": "^30.3.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jsdom": "^28.1.0"
|
"jsdom": "^28.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Configuration for Railtrack Pro
|
||||||
|
* E2E testing with video recording
|
||||||
|
*/
|
||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './test/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:8080',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'on-first-retry'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Playwright E2E Tests for Railtrack Pro
|
||||||
|
* Tests core game functionality with video recording on failure
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Railtrack Pro E2E Tests', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load game page successfully', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/Railtrack Pro/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render Three.js canvas', async ({ page }) => {
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should initialize game world', async ({ page }) => {
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const rect = await canvas.boundingBox();
|
||||||
|
expect(rect).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should respond to mouse interactions', async ({ page }) => {
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
await canvas.hover();
|
||||||
|
console.log('Mouse interaction successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
console.log('Playwright E2E test file created successfully');
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Train Module Unit Tests
|
||||||
|
* Tests for Train and TrainController classes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Train, TrainController } = require('../../js/train.js');
|
||||||
|
|
||||||
|
describe('Train Module', () => {
|
||||||
|
describe('Train Class', () => {
|
||||||
|
test('should create train with default properties', () => {
|
||||||
|
const train = new Train();
|
||||||
|
expect(train.id).toBeDefined();
|
||||||
|
expect(train.speed).toBe(0);
|
||||||
|
expect(train.maxSpeed).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should initialize with custom properties', () => {
|
||||||
|
const train = new Train({ id: 'TEST', speed: 10, maxSpeed: 20 });
|
||||||
|
expect(train.id).toBe('TEST');
|
||||||
|
expect(train.speed).toBe(10);
|
||||||
|
expect(train.maxSpeed).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accelerate and brake', () => {
|
||||||
|
const train = new Train({ speed: 0, maxSpeed: 10 });
|
||||||
|
|
||||||
|
train.accelerate();
|
||||||
|
expect(train.speed).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
train.brake();
|
||||||
|
expect(train.speed).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reverse direction', () => {
|
||||||
|
const train = new Train({ speed: 10, direction: 1 });
|
||||||
|
|
||||||
|
train.reverse();
|
||||||
|
expect(train.direction).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TrainController Class', () => {
|
||||||
|
test('should create controller with default config', () => {
|
||||||
|
const controller = new TrainController();
|
||||||
|
expect(controller.trackPath).toEqual([]);
|
||||||
|
expect(controller.progress).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept track path configuration', () => {
|
||||||
|
const path = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 10, y: 0 },
|
||||||
|
{ x: 10, y: 10 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const controller = new TrainController(path);
|
||||||
|
expect(controller.trackPath.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should calculate progress along track', () => {
|
||||||
|
const path = [{ x: 0, y: 0 }, { x: 10, y: 0 }];
|
||||||
|
const controller = new TrainController(path);
|
||||||
|
|
||||||
|
controller.setDistance(5);
|
||||||
|
expect(controller.progress).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(controller.progress).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return current position on track', () => {
|
||||||
|
const path = [{ x: 0, y: 0 }, { x: 10, y: 0 }];
|
||||||
|
const controller = new TrainController(path);
|
||||||
|
|
||||||
|
const position = controller.getCurrentPosition();
|
||||||
|
expect(position).toBeDefined();
|
||||||
|
expect(position.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(position.x).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user