export class GameRenderer { constructor() { this.scene = null; this.camera = null; this.renderer = null; this.canvas = null; this.tiles = new Map(); // Map of tile meshes this.buildings = new Map(); // Map of building meshes this.cursors = new Map(); // Map of player cursors this.labels = new Map(); // Map of building labels this.hoveredTile = null; this.cameraPos = { x: 0, y: 0, z: 0 }; this.cameraZoom = 1; // Re-introduced for proper orthographic zoom this.TILE_SIZE = 2; this.VIEW_DISTANCE = 50; } init() { this.canvas = document.getElementById('gameCanvas'); // Create scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x87CEEB); // Sky blue // Create camera this.camera = new THREE.OrthographicCamera( -40, 40, 30, -30, 0.1, 1000 ); // Transport Tycoon style isometric view this.camera.position.set(50, 50, 50); this.camera.lookAt(0, 0, 0); // Create renderer this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Soft shadows // Add lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); // Reduced ambient for better shadows this.scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); // Increased intensity directionalLight.position.set(30, 40, 30); // Higher position for better shadows directionalLight.castShadow = true; // Configure shadow properties for better quality directionalLight.shadow.mapSize.width = 2048; directionalLight.shadow.mapSize.height = 2048; directionalLight.shadow.camera.near = 0.5; directionalLight.shadow.camera.far = 200; // Set shadow camera bounds to cover the play area const shadowDistance = 50; directionalLight.shadow.camera.left = -shadowDistance; directionalLight.shadow.camera.right = shadowDistance; directionalLight.shadow.camera.top = shadowDistance; directionalLight.shadow.camera.bottom = -shadowDistance; directionalLight.shadow.bias = -0.0001; // Reduce shadow acne this.scene.add(directionalLight); // Create ground this.createGround(); // Create grid this.createGrid(); // Handle window resize window.addEventListener('resize', () => this.onResize()); } createGround() { const geometry = new THREE.PlaneGeometry(1000, 1000); const material = new THREE.MeshLambertMaterial({ color: 0x228B22 }); // Forest green const ground = new THREE.Mesh(geometry, material); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; this.scene.add(ground); } createGrid() { const gridGroup = new THREE.Group(); const gridSize = 200; // Grid extends from -100 to +100 const divisions = gridSize / this.TILE_SIZE; // Number of divisions // Create grid lines material - subtle gray const gridMaterial = new THREE.LineBasicMaterial({ color: 0x666666, transparent: true, opacity: 0.3 }); // Create vertical lines (parallel to Z axis) // Offset by half tile size so grid squares are centered on buildings for (let i = -divisions/2; i <= divisions/2; i++) { const geometry = new THREE.BufferGeometry(); const x = (i * this.TILE_SIZE) - (this.TILE_SIZE / 2); const vertices = new Float32Array([ x, 0.02, -gridSize/2, // Start point x, 0.02, gridSize/2 // End point ]); geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); const line = new THREE.Line(geometry, gridMaterial); gridGroup.add(line); } // Create horizontal lines (parallel to X axis) // Offset by half tile size so grid squares are centered on buildings for (let i = -divisions/2; i <= divisions/2; i++) { const geometry = new THREE.BufferGeometry(); const z = (i * this.TILE_SIZE) - (this.TILE_SIZE / 2); const vertices = new Float32Array([ -gridSize/2, 0.02, z, // Start point gridSize/2, 0.02, z // End point ]); geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); const line = new THREE.Line(geometry, gridMaterial); gridGroup.add(line); } this.scene.add(gridGroup); } createTile(x, y, color = 0x90EE90) { const geometry = new THREE.PlaneGeometry(this.TILE_SIZE - 0.1, this.TILE_SIZE - 0.1); const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5 }); const tile = new THREE.Mesh(geometry, material); tile.position.set(x * this.TILE_SIZE, 0.01, y * this.TILE_SIZE); tile.rotation.x = -Math.PI / 2; tile.userData = { x, y }; return tile; } createBuilding(buildingData) { const { type, x, y, owner_id, name } = buildingData; // Get player color for accents const playerColor = this.getPlayerColor(owner_id); // Determine building orientation based on adjacent roads const orientation = this.getBuildingOrientation(x, y, type); // Create building group const buildingGroup = new THREE.Group(); buildingGroup.position.set(x * this.TILE_SIZE, 0, y * this.TILE_SIZE); buildingGroup.userData = { x, y, owner_id, type, name }; // Add foundation/base for all non-road buildings if (type !== 'road') { const foundation = this.createTileFoundation(); buildingGroup.add(foundation); } // Create building based on type let buildingMesh; if (type.includes('house')) { buildingMesh = this.createHouse(type, playerColor, orientation); } else if (type.includes('shop') || type === 'supermarket' || type === 'mall') { buildingMesh = this.createCommercialBuilding(type, playerColor, orientation); } else if (type.includes('factory')) { buildingMesh = this.createFactory(type, playerColor, orientation); } else if (type === 'road') { buildingMesh = this.createRoad(); } else if (type === 'park' || type === 'plaza') { buildingMesh = this.createPark(type, playerColor); } else if (type === 'town_hall') { buildingMesh = this.createTownHall(playerColor, orientation); } else if (type === 'power_plant') { buildingMesh = this.createPowerPlant(playerColor, orientation); } else { // Fallback to simple building buildingMesh = this.createSimpleBuilding(2, 0x808080); } buildingGroup.add(buildingMesh); // Add name label if the building has a name if (name) { const label = this.createBuildingLabel(name, playerColor); buildingGroup.add(label); } return buildingGroup; } getPlayerColor(owner_id) { // Try to find the player color from game state or use default if (this.gameState && this.gameState.players && this.gameState.players[owner_id]) { const color = this.gameState.players[owner_id].color; return new THREE.Color(color); } return new THREE.Color(0xff6b6b); // Default reddish color } getBuildingOrientation(x, y, buildingType) { // Roads and parks don't need orientation if (buildingType === 'road' || buildingType === 'park' || buildingType === 'plaza') { return 0; } // Check adjacent tiles for roads const adjacentRoads = this.getAdjacentRoads(x, y); // If no roads, face south (default) if (adjacentRoads.length === 0) { return 0; // 0 = south, 90 = west, 180 = north, 270 = east } // Priority order: south, east, north, west // Buildings prefer to face south, then east, etc. const priorities = ['south', 'east', 'north', 'west']; for (const direction of priorities) { if (adjacentRoads.includes(direction)) { switch (direction) { case 'south': return 0; // Face south (positive Z) case 'east': return 270; // Face east (positive X) case 'north': return 180; // Face north (negative Z) case 'west': return 90; // Face west (negative X) } } } return 0; // Default to south } getAdjacentRoads(x, y) { const roads = []; if (!this.gameState || !this.gameState.buildings) return roads; const directions = [ { dir: 'north', dx: 0, dy: -1 }, { dir: 'south', dx: 0, dy: 1 }, { dir: 'east', dx: 1, dy: 0 }, { dir: 'west', dx: -1, dy: 0 } ]; for (const { dir, dx, dy } of directions) { const checkX = x + dx; const checkY = y + dy; const key = `${checkX},${checkY}`; const building = this.gameState.buildings[key]; if (building && building.type === 'road') { roads.push(dir); } } return roads; } createTileFoundation() { // Create a full tile-sized foundation with road color const geometry = new THREE.BoxGeometry( this.TILE_SIZE, 0.05, this.TILE_SIZE ); const material = new THREE.MeshLambertMaterial({ color: 0x2F4F4F }); // Same as road color const foundation = new THREE.Mesh(geometry, material); foundation.position.y = 0.025; // Half the foundation height foundation.castShadow = true; foundation.receiveShadow = true; return foundation; } createSimpleBuilding(height, color) { const geometry = new THREE.BoxGeometry( this.TILE_SIZE - 0.2, height, this.TILE_SIZE - 0.2 ); const material = new THREE.MeshLambertMaterial({ color: color }); const building = new THREE.Mesh(geometry, material); building.position.y = height / 2; building.castShadow = true; building.receiveShadow = true; return building; } createHouse(type, playerColor, orientation = 0) { const group = new THREE.Group(); // Determine house size and height let houseWidth = 1.4; let houseDepth = 1.2; let wallHeight = 1.5; let roofHeight = 0.8; if (type === 'medium_house') { houseWidth = 1.6; houseDepth = 1.4; wallHeight = 2.0; roofHeight = 1.0; } else if (type === 'large_house') { houseWidth = 1.7; houseDepth = 1.6; wallHeight = 2.5; roofHeight = 1.2; } // Main house body const wallGeometry = new THREE.BoxGeometry(houseWidth, wallHeight, houseDepth); const wallMaterial = new THREE.MeshLambertMaterial({ color: 0xf4f4f4 }); const walls = new THREE.Mesh(wallGeometry, wallMaterial); walls.position.y = wallHeight / 2; walls.castShadow = true; walls.receiveShadow = true; group.add(walls); // Roof (triangular prism) - player color const roofGeometry = new THREE.CylinderGeometry(0, houseWidth * 0.8, roofHeight, 4); const roofMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const roof = new THREE.Mesh(roofGeometry, roofMaterial); roof.position.y = wallHeight + roofHeight / 2; roof.rotation.y = Math.PI / 4; // Rotate 45 degrees to make it diamond-shaped roof.castShadow = true; group.add(roof); // Door - position based on orientation (always faces road) const doorGeometry = new THREE.BoxGeometry(0.3, 0.6, 0.05); const doorMaterial = new THREE.MeshLambertMaterial({ color: 0x8b4513 }); const door = new THREE.Mesh(doorGeometry, doorMaterial); // Position door based on orientation switch (orientation) { case 0: // Face south (positive Z) door.position.set(0, 0.3, houseDepth / 2 + 0.02); break; case 90: // Face west (negative X) door.position.set(-houseWidth / 2 - 0.02, 0.3, 0); door.rotation.y = Math.PI / 2; break; case 180: // Face north (negative Z) door.position.set(0, 0.3, -houseDepth / 2 - 0.02); door.rotation.y = Math.PI; break; case 270: // Face east (positive X) door.position.set(houseWidth / 2 + 0.02, 0.3, 0); door.rotation.y = -Math.PI / 2; break; } group.add(door); // Windows with player color frames const createWindow = (x, y, z) => { const windowGroup = new THREE.Group(); // Window frame - player color const frameGeometry = new THREE.BoxGeometry(0.35, 0.35, 0.05); const frameMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const frame = new THREE.Mesh(frameGeometry, frameMaterial); windowGroup.add(frame); // Window glass const glassGeometry = new THREE.BoxGeometry(0.25, 0.25, 0.02); const glassMaterial = new THREE.MeshLambertMaterial({ color: 0x87ceeb, transparent: true, opacity: 0.7 }); const glass = new THREE.Mesh(glassGeometry, glassMaterial); glass.position.z = 0.01; windowGroup.add(glass); windowGroup.position.set(x, y, z); return windowGroup; }; // Add windows - position based on orientation (front-facing) switch (orientation) { case 0: // Face south (positive Z) group.add(createWindow(-houseWidth/3, wallHeight/2, houseDepth/2 + 0.02)); group.add(createWindow(houseWidth/3, wallHeight/2, houseDepth/2 + 0.02)); break; case 90: // Face west (negative X) group.add(createWindow(-houseWidth/2 - 0.02, wallHeight/2, -houseDepth/3)); group.add(createWindow(-houseWidth/2 - 0.02, wallHeight/2, houseDepth/3)); break; case 180: // Face north (negative Z) group.add(createWindow(-houseWidth/3, wallHeight/2, -houseDepth/2 - 0.02)); group.add(createWindow(houseWidth/3, wallHeight/2, -houseDepth/2 - 0.02)); break; case 270: // Face east (positive X) group.add(createWindow(houseWidth/2 + 0.02, wallHeight/2, -houseDepth/3)); group.add(createWindow(houseWidth/2 + 0.02, wallHeight/2, houseDepth/3)); break; } // Chimney for medium and large houses if (type !== 'small_house') { const chimneyGeometry = new THREE.BoxGeometry(0.2, 0.6, 0.2); const chimneyMaterial = new THREE.MeshLambertMaterial({ color: 0x696969 }); const chimney = new THREE.Mesh(chimneyGeometry, chimneyMaterial); chimney.position.set(houseWidth/3, wallHeight + roofHeight/2 + 0.3, -houseDepth/4); chimney.castShadow = true; group.add(chimney); } return group; } createCommercialBuilding(type, playerColor, orientation = 0) { const group = new THREE.Group(); let width = 1.6; let height = 2.5; let depth = 1.4; let signColor = playerColor; if (type === 'supermarket') { width = 1.7; height = 3.0; depth = 1.6; } else if (type === 'mall') { width = 1.8; height = 3.5; depth = 1.7; } // Main building const buildingGeometry = new THREE.BoxGeometry(width, height, depth); const buildingMaterial = new THREE.MeshLambertMaterial({ color: 0xe6e6e6 }); const building = new THREE.Mesh(buildingGeometry, buildingMaterial); building.position.y = height / 2; building.castShadow = true; building.receiveShadow = true; group.add(building); // Storefront elements - position based on orientation const signGeometry = new THREE.BoxGeometry(width * 0.9, 0.3, 0.05); const signMaterial = new THREE.MeshLambertMaterial({ color: signColor }); const sign = new THREE.Mesh(signGeometry, signMaterial); const windowGeometry = new THREE.BoxGeometry(width * 0.7, height * 0.4, 0.05); const windowMaterial = new THREE.MeshLambertMaterial({ color: 0x4169e1, transparent: true, opacity: 0.6 }); const windows = new THREE.Mesh(windowGeometry, windowMaterial); const doorGeometry = new THREE.BoxGeometry(0.4, 0.8, 0.05); const doorMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const door = new THREE.Mesh(doorGeometry, doorMaterial); // Position storefront based on orientation switch (orientation) { case 0: // Face south (positive Z) sign.position.set(0, height * 0.8, depth / 2 + 0.02); windows.position.set(0, height * 0.3, depth / 2 + 0.01); door.position.set(0, 0.4, depth / 2 + 0.02); break; case 90: // Face west (negative X) sign.position.set(-width / 2 - 0.02, height * 0.8, 0); sign.rotation.y = Math.PI / 2; windows.position.set(-width / 2 - 0.01, height * 0.3, 0); windows.rotation.y = Math.PI / 2; door.position.set(-width / 2 - 0.02, 0.4, 0); door.rotation.y = Math.PI / 2; break; case 180: // Face north (negative Z) sign.position.set(0, height * 0.8, -depth / 2 - 0.02); sign.rotation.y = Math.PI; windows.position.set(0, height * 0.3, -depth / 2 - 0.01); windows.rotation.y = Math.PI; door.position.set(0, 0.4, -depth / 2 - 0.02); door.rotation.y = Math.PI; break; case 270: // Face east (positive X) sign.position.set(width / 2 + 0.02, height * 0.8, 0); sign.rotation.y = -Math.PI / 2; windows.position.set(width / 2 + 0.01, height * 0.3, 0); windows.rotation.y = -Math.PI / 2; door.position.set(width / 2 + 0.02, 0.4, 0); door.rotation.y = -Math.PI / 2; break; } group.add(sign); group.add(windows); group.add(door); // Air conditioning units on roof for larger buildings if (type === 'supermarket' || type === 'mall') { const acGeometry = new THREE.BoxGeometry(0.4, 0.2, 0.3); const acMaterial = new THREE.MeshLambertMaterial({ color: 0x555555 }); const ac1 = new THREE.Mesh(acGeometry, acMaterial); ac1.position.set(-width/3, height + 0.1, 0); group.add(ac1); const ac2 = new THREE.Mesh(acGeometry, acMaterial); ac2.position.set(width/3, height + 0.1, 0); group.add(ac2); } return group; } createFactory(type, playerColor, orientation = 0) { const group = new THREE.Group(); let width = 1.6; let height = 3.5; let depth = 1.5; if (type === 'large_factory') { width = 1.8; height = 4.5; depth = 1.7; } // Main factory building const buildingGeometry = new THREE.BoxGeometry(width, height, depth); const buildingMaterial = new THREE.MeshLambertMaterial({ color: 0x696969 }); const building = new THREE.Mesh(buildingGeometry, buildingMaterial); building.position.y = height / 2; building.castShadow = true; building.receiveShadow = true; group.add(building); // Smokestack - player color stripe const stackHeight = height + 1.5; const stackGeometry = new THREE.CylinderGeometry(0.1, 0.15, stackHeight); const stackMaterial = new THREE.MeshLambertMaterial({ color: 0x404040 }); const stack = new THREE.Mesh(stackGeometry, stackMaterial); stack.position.set(width/3, stackHeight/2, -depth/4); stack.castShadow = true; group.add(stack); // Player color stripe on smokestack const stripeGeometry = new THREE.CylinderGeometry(0.12, 0.16, 0.3); const stripeMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const stripe = new THREE.Mesh(stripeGeometry, stripeMaterial); stripe.position.set(width/3, stackHeight * 0.7, -depth/4); group.add(stripe); // Factory windows const createFactoryWindow = (x, y, z) => { const windowGeometry = new THREE.BoxGeometry(0.3, 0.4, 0.05); const windowMaterial = new THREE.MeshLambertMaterial({ color: 0xffff88, transparent: true, opacity: 0.8 }); const window = new THREE.Mesh(windowGeometry, windowMaterial); window.position.set(x, y, z); return window; }; // Add multiple windows for (let i = -1; i <= 1; i++) { for (let j = 1; j <= 2; j++) { group.add(createFactoryWindow(i * width/3, j * height/3, depth/2 + 0.01)); } } // Loading dock - player color const dockGeometry = new THREE.BoxGeometry(0.6, 0.1, 0.4); const dockMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const dock = new THREE.Mesh(dockGeometry, dockMaterial); dock.position.set(-width/3, 0.05, depth/2 + 0.2); group.add(dock); return group; } createRoad() { const geometry = new THREE.BoxGeometry( this.TILE_SIZE, 0.1, this.TILE_SIZE ); const material = new THREE.MeshLambertMaterial({ color: 0x2F4F4F }); const road = new THREE.Mesh(geometry, material); road.position.y = 0.05; road.castShadow = true; road.receiveShadow = true; return road; } createPark(type, playerColor) { const group = new THREE.Group(); // Base green area const baseGeometry = new THREE.BoxGeometry(this.TILE_SIZE - 0.1, 0.1, this.TILE_SIZE - 0.1); const baseMaterial = new THREE.MeshLambertMaterial({ color: 0x32cd32 }); const base = new THREE.Mesh(baseGeometry, baseMaterial); base.position.y = 0.05; base.receiveShadow = true; group.add(base); if (type === 'park') { // Add trees const createTree = (x, z) => { const treeGroup = new THREE.Group(); // Trunk const trunkGeometry = new THREE.CylinderGeometry(0.05, 0.08, 0.5); const trunkMaterial = new THREE.MeshLambertMaterial({ color: 0x8b4513 }); const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial); trunk.position.y = 0.25; trunk.castShadow = true; treeGroup.add(trunk); // Leaves - player color accent const leavesGeometry = new THREE.SphereGeometry(0.3, 8, 6); const leavesMaterial = new THREE.MeshLambertMaterial({ color: playerColor.clone().lerp(new THREE.Color(0x228b22), 0.7) }); const leaves = new THREE.Mesh(leavesGeometry, leavesMaterial); leaves.position.y = 0.6; leaves.castShadow = true; treeGroup.add(leaves); treeGroup.position.set(x, 0, z); return treeGroup; }; group.add(createTree(-0.4, -0.4)); group.add(createTree(0.4, 0.4)); } else { // plaza // Add fountain with player color accents const fountainGeometry = new THREE.CylinderGeometry(0.3, 0.4, 0.3); const fountainMaterial = new THREE.MeshLambertMaterial({ color: 0xd3d3d3 }); const fountain = new THREE.Mesh(fountainGeometry, fountainMaterial); fountain.position.y = 0.25; fountain.castShadow = true; group.add(fountain); // Water with player color tint const waterGeometry = new THREE.CylinderGeometry(0.25, 0.25, 0.05); const waterMaterial = new THREE.MeshLambertMaterial({ color: playerColor.clone().lerp(new THREE.Color(0x4169e1), 0.5), transparent: true, opacity: 0.7 }); const water = new THREE.Mesh(waterGeometry, waterMaterial); water.position.y = 0.35; group.add(water); } return group; } createTownHall(playerColor, orientation = 0) { const group = new THREE.Group(); // Main building const mainGeometry = new THREE.BoxGeometry(1.7, 4.0, 1.6); const mainMaterial = new THREE.MeshLambertMaterial({ color: 0xf5f5dc }); const main = new THREE.Mesh(mainGeometry, mainMaterial); main.position.y = 2.0; main.castShadow = true; main.receiveShadow = true; group.add(main); // Clock tower const towerGeometry = new THREE.BoxGeometry(0.6, 1.5, 0.6); const towerMaterial = new THREE.MeshLambertMaterial({ color: 0xe6e6e6 }); const tower = new THREE.Mesh(towerGeometry, towerMaterial); tower.position.y = 4.75; tower.castShadow = true; group.add(tower); // Clock face - player color const clockGeometry = new THREE.CylinderGeometry(0.2, 0.2, 0.05); const clockMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const clock = new THREE.Mesh(clockGeometry, clockMaterial); clock.position.set(0, 4.5, 0.3); clock.rotation.x = Math.PI / 2; group.add(clock); // Flag pole with player color flag const poleGeometry = new THREE.CylinderGeometry(0.02, 0.02, 1.0); const poleMaterial = new THREE.MeshLambertMaterial({ color: 0x555555 }); const pole = new THREE.Mesh(poleGeometry, poleMaterial); pole.position.set(0.6, 5.5, 0); group.add(pole); const flagGeometry = new THREE.PlaneGeometry(0.3, 0.2); const flagMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const flag = new THREE.Mesh(flagGeometry, flagMaterial); flag.position.set(0.75, 5.7, 0); group.add(flag); // Steps with player color carpet const stepsGeometry = new THREE.BoxGeometry(1.8, 0.2, 0.6); const stepsMaterial = new THREE.MeshLambertMaterial({ color: 0xd3d3d3 }); const steps = new THREE.Mesh(stepsGeometry, stepsMaterial); steps.position.set(0, 0.1, 0.8); group.add(steps); const carpetGeometry = new THREE.BoxGeometry(0.4, 0.01, 0.6); const carpetMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const carpet = new THREE.Mesh(carpetGeometry, carpetMaterial); carpet.position.set(0, 0.21, 0.8); group.add(carpet); return group; } createPowerPlant(playerColor, orientation = 0) { const group = new THREE.Group(); // Main building const mainGeometry = new THREE.BoxGeometry(1.8, 3.0, 1.7); const mainMaterial = new THREE.MeshLambertMaterial({ color: 0x555555 }); const main = new THREE.Mesh(mainGeometry, mainMaterial); main.position.y = 1.5; main.castShadow = true; main.receiveShadow = true; group.add(main); // Cooling towers const createCoolingTower = (x, z) => { const towerGeometry = new THREE.CylinderGeometry(0.2, 0.3, 4.0); const towerMaterial = new THREE.MeshLambertMaterial({ color: 0x888888 }); const tower = new THREE.Mesh(towerGeometry, towerMaterial); tower.position.set(x, 2.0, z); tower.castShadow = true; // Player color stripe on tower const stripeGeometry = new THREE.CylinderGeometry(0.22, 0.32, 0.3); const stripeMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const stripe = new THREE.Mesh(stripeGeometry, stripeMaterial); stripe.position.set(x, 3.0, z); group.add(stripe); return tower; }; group.add(createCoolingTower(-0.5, -0.3)); group.add(createCoolingTower(0.5, -0.3)); // Control room with player color accents const controlGeometry = new THREE.BoxGeometry(0.8, 1.0, 0.6); const controlMaterial = new THREE.MeshLambertMaterial({ color: 0x777777 }); const control = new THREE.Mesh(controlGeometry, controlMaterial); control.position.set(0, 3.5, 0.4); control.castShadow = true; group.add(control); // Control room windows - player color frames const windowGeometry = new THREE.BoxGeometry(0.6, 0.3, 0.05); const windowMaterial = new THREE.MeshLambertMaterial({ color: playerColor }); const windows = new THREE.Mesh(windowGeometry, windowMaterial); windows.position.set(0, 3.5, 0.7); group.add(windows); // Power lines const lineGeometry = new THREE.CylinderGeometry(0.01, 0.01, 2.0); const lineMaterial = new THREE.MeshLambertMaterial({ color: 0x000000 }); const line = new THREE.Mesh(lineGeometry, lineMaterial); line.position.set(0.8, 4.0, 0); line.rotation.z = Math.PI / 6; group.add(line); return group; } createBuildingLabel(name, playerColor) { // Create canvas for text rendering const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // Much larger canvas for better visibility canvas.width = 1024; canvas.height = 256; // Clear background ctx.clearRect(0, 0, canvas.width, canvas.height); // Much bigger text for better visibility const fontSize = 120; ctx.font = `bold ${fontSize}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Draw colored background const padding = 32; const textWidth = ctx.measureText(name).width; const bgWidth = Math.max(textWidth + padding * 2, 300); const bgHeight = fontSize + padding * 2; // Fill with player color ctx.fillStyle = `rgb(${Math.floor(playerColor.r * 255)}, ${Math.floor(playerColor.g * 255)}, ${Math.floor(playerColor.b * 255)})`; ctx.fillRect( (canvas.width - bgWidth) / 2, (canvas.height - bgHeight) / 2, bgWidth, bgHeight ); // Draw black text ctx.fillStyle = 'black'; ctx.fillText(name, canvas.width / 2, canvas.height / 2); // Create texture and material const texture = new THREE.CanvasTexture(canvas); const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthTest: false // Always render on top }); // Much larger geometry - 2x bigger than before const geometry = new THREE.PlaneGeometry(8, 2); const labelMesh = new THREE.Mesh(geometry, material); // Position above building labelMesh.position.set(0, 4.5, 0); // Make labels always face camera (billboard effect) and appear horizontal // This will be updated in the render loop to always face the camera labelMesh.userData.isBillboard = true; // Initial rotation to face the isometric camera // Camera is positioned at (cameraPos.x + 50, 50, cameraPos.z + 50) // We want labels to face northeast towards camera but remain horizontal labelMesh.rotation.y = -Math.PI / 4; // Face camera direction labelMesh.rotation.x = 0; // Keep horizontal (no tilt) // Mark as building label labelMesh.userData.isBuildingLabel = true; return labelMesh; } createCursor(playerId, color) { const geometry = new THREE.RingGeometry(0.5, 0.7, 16); const material = new THREE.MeshBasicMaterial({ color: color, side: THREE.DoubleSide }); const cursor = new THREE.Mesh(geometry, material); cursor.rotation.x = -Math.PI / 2; cursor.position.y = 0.02; return cursor; } updateGameState(gameState) { // Store game state for player color access this.gameState = gameState; // Clear existing buildings this.buildings.forEach(mesh => this.scene.remove(mesh)); this.buildings.clear(); // Add all buildings Object.values(gameState.buildings).forEach(building => { this.addBuilding(building); }); } addBuilding(buildingData) { const key = `${buildingData.x},${buildingData.y}`; // Remove existing building at this position if (this.buildings.has(key)) { this.scene.remove(this.buildings.get(key)); } // Create and add new building const building = this.createBuilding(buildingData); this.buildings.set(key, building); this.scene.add(building); // If this is a road, update adjacent building orientations if (buildingData.type === 'road') { this.updateAdjacentBuildingOrientations(buildingData.x, buildingData.y); } } updateAdjacentBuildingOrientations(roadX, roadY) { if (!this.gameState || !this.gameState.buildings) return; const directions = [ { dx: 0, dy: -1 }, // North { dx: 0, dy: 1 }, // South { dx: 1, dy: 0 }, // East { dx: -1, dy: 0 } // West ]; for (const { dx, dy } of directions) { const checkX = roadX + dx; const checkY = roadY + dy; const key = `${checkX},${checkY}`; const buildingData = this.gameState.buildings[key]; if (buildingData && buildingData.type !== 'road') { // Rebuild this building with new orientation this.removeBuilding(checkX, checkY); this.addBuilding(buildingData); } } } removeBuilding(x, y) { const key = `${x},${y}`; if (this.buildings.has(key)) { this.scene.remove(this.buildings.get(key)); this.buildings.delete(key); } } updateBuildingName(x, y, name) { const key = `${x},${y}`; const building = this.buildings.get(key); if (!building) { return; } // Update building userData building.userData.name = name; // Remove all existing labels const labelsToRemove = []; building.children.forEach((child, index) => { if (child.userData && child.userData.isBuildingLabel) { labelsToRemove.push(child); } }); labelsToRemove.forEach(label => { building.remove(label); // Clean up resources if (label.material) { if (label.material.map) label.material.map.dispose(); label.material.dispose(); } if (label.geometry) label.geometry.dispose(); }); // Add new label if name provided if (name && name.trim()) { const playerColor = this.getPlayerColor(building.userData.owner_id); const newLabel = this.createBuildingLabel(name.trim(), playerColor); building.add(newLabel); } } updateCursor(playerId, x, y) { if (!this.cursors.has(playerId)) { const cursor = this.createCursor(playerId, 0xff0000); this.cursors.set(playerId, cursor); this.scene.add(cursor); } const cursor = this.cursors.get(playerId); cursor.position.x = x * this.TILE_SIZE; cursor.position.z = y * this.TILE_SIZE; } removeCursor(playerId) { if (this.cursors.has(playerId)) { this.scene.remove(this.cursors.get(playerId)); this.cursors.delete(playerId); } } highlightTile(x, y) { // Remove previous highlight if (this.hoveredTile) { this.scene.remove(this.hoveredTile); this.hoveredTile = null; } // Create new highlight if (x !== null && y !== null) { this.hoveredTile = this.createTile(x, y, 0xFFFF00); this.scene.add(this.hoveredTile); } } screenToWorld(screenX, screenY) { const rect = this.canvas.getBoundingClientRect(); const x = ((screenX - rect.left) / rect.width) * 2 - 1; const y = -((screenY - rect.top) / rect.height) * 2 + 1; const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(new THREE.Vector2(x, y), this.camera); // Raycast to ground plane const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); const intersection = new THREE.Vector3(); raycaster.ray.intersectPlane(plane, intersection); return { x: Math.floor(intersection.x / this.TILE_SIZE), y: Math.floor(intersection.z / this.TILE_SIZE) }; } moveCamera(dx, dy) { this.cameraPos.x += dx; this.cameraPos.z += dy; this.updateCameraPosition(); } zoomCamera(delta) { // Adjust the zoom property. A positive delta (scroll up) increases zoom. this.cameraZoom += delta; // Clamp the zoom level to a reasonable range this.cameraZoom = Math.max(0.5, Math.min(2.5, this.cameraZoom)); // Apply the zoom to the camera and update its projection matrix this.camera.zoom = this.cameraZoom; this.camera.updateProjectionMatrix(); } updateCameraPosition() { // Transport Tycoon style isometric positioning this.camera.position.set( this.cameraPos.x + 50, 50, this.cameraPos.z + 50 ); this.camera.lookAt( this.cameraPos.x, 0, this.cameraPos.z ); } startRenderLoop() { const animate = () => { requestAnimationFrame(animate); // Update billboard labels to face camera this.updateBillboards(); this.renderer.render(this.scene, this.camera); }; animate(); } updateBillboards() { // Make every billboard label face the camera horizontally (yaw only) const camPos = this.camera.position; this.buildings.forEach(building => { building.traverse(child => { if (child.userData && child.userData.isBillboard) { // Get world position of label const worldPos = new THREE.Vector3(); child.getWorldPosition(worldPos); // Compute angle to camera on XZ plane const dx = camPos.x - worldPos.x; const dz = camPos.z - worldPos.z; const angle = Math.atan2(dx, dz); // radians // Apply rotation: yaw = angle, keep flat (no pitch/roll) child.rotation.set(0, angle, 0); } }); }); } onResize() { const aspect = window.innerWidth / window.innerHeight; this.camera.left = -40 * aspect; this.camera.right = 40 * aspect; this.camera.updateProjectionMatrix(); this.renderer.setSize(window.innerWidth, window.innerHeight); } }