Compare commits
13
Commits
94ee71f822
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bee9f24a5 | ||
|
|
3d24bbc61f | ||
|
|
bcc24bc6fc | ||
|
|
0cf4072ce5 | ||
|
|
fdf3032ae7 | ||
|
|
2fb014fd40 | ||
|
|
3093e38f0e | ||
|
|
6b90372eef | ||
|
|
f11b2f4c87 | ||
|
|
f80f8a5dc0 | ||
|
|
26b2551cc9 | ||
|
|
8441695d0a | ||
|
|
f52cc992be |
@@ -105,7 +105,13 @@ class GameState:
|
|||||||
if building.owner_id != player_id:
|
if building.owner_id != player_id:
|
||||||
return {"success": False, "error": "You don't own this building"}
|
return {"success": False, "error": "You don't own this building"}
|
||||||
|
|
||||||
building.name = name
|
# Validate name length (max 20 characters)
|
||||||
|
name = name.strip()
|
||||||
|
if len(name) > 20:
|
||||||
|
return {"success": False, "error": "Name too long (max 20 characters)"}
|
||||||
|
|
||||||
|
# Empty name is allowed (removes the label)
|
||||||
|
building.name = name if name else None
|
||||||
logger.info(f"Player {player_id} renamed building at ({x},{y}) to '{name}'.")
|
logger.info(f"Player {player_id} renamed building at ({x},{y}) to '{name}'.")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -22,10 +22,17 @@ def initialize_components(test_db_path=None):
|
|||||||
"""Initialize server components with optional test database"""
|
"""Initialize server components with optional test database"""
|
||||||
global game_state, ws_manager, economy_engine, database
|
global game_state, ws_manager, economy_engine, database
|
||||||
|
|
||||||
|
# Initialize database first
|
||||||
|
database = Database(test_db_path) if test_db_path else Database()
|
||||||
|
database.init_db()
|
||||||
|
|
||||||
|
# Create game state and load data from database
|
||||||
game_state = GameState()
|
game_state = GameState()
|
||||||
|
game_state.load_state(database.load_game_state())
|
||||||
|
|
||||||
|
# Now create WebSocketManager (it will rebuild nickname mapping from loaded players)
|
||||||
ws_manager = WebSocketManager(game_state)
|
ws_manager = WebSocketManager(game_state)
|
||||||
economy_engine = EconomyEngine(game_state)
|
economy_engine = EconomyEngine(game_state)
|
||||||
database = Database(test_db_path) if test_db_path else Database()
|
|
||||||
|
|
||||||
return game_state, ws_manager, economy_engine, database
|
return game_state, ws_manager, economy_engine, database
|
||||||
|
|
||||||
@@ -84,8 +91,7 @@ async def lifespan(app: FastAPI):
|
|||||||
initialize_components()
|
initialize_components()
|
||||||
|
|
||||||
logger.info("Server starting up...")
|
logger.info("Server starting up...")
|
||||||
database.init_db()
|
# Database and game state are already initialized in initialize_components()
|
||||||
game_state.load_state(database.load_game_state())
|
|
||||||
|
|
||||||
# Start the hybrid economy loop
|
# Start the hybrid economy loop
|
||||||
task = asyncio.create_task(economy_loop())
|
task = asyncio.create_task(economy_loop())
|
||||||
@@ -113,9 +119,17 @@ async def websocket_endpoint(websocket: WebSocket, nickname: str):
|
|||||||
player_id = await ws_manager.get_player_id(websocket)
|
player_id = await ws_manager.get_player_id(websocket)
|
||||||
if player_id:
|
if player_id:
|
||||||
player = game_state.get_or_create_player(nickname, player_id)
|
player = game_state.get_or_create_player(nickname, player_id)
|
||||||
|
|
||||||
|
# Trigger economy update on login to ensure player stats are current
|
||||||
|
# This is especially important after server restarts
|
||||||
|
logger.info(f"Player {player_id} connected, triggering economy update to sync stats.")
|
||||||
|
await trigger_economy_update_and_save()
|
||||||
|
|
||||||
|
# Send updated player data after economy sync
|
||||||
|
updated_player = game_state.players.get(player_id, player)
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "init",
|
"type": "init",
|
||||||
"player": player.to_dict(),
|
"player": updated_player.to_dict(),
|
||||||
"game_state": game_state.get_state()
|
"game_state": game_state.get_state()
|
||||||
})
|
})
|
||||||
while True:
|
while True:
|
||||||
@@ -167,6 +181,8 @@ async def handle_message(websocket: WebSocket, data: dict):
|
|||||||
if result["success"]:
|
if result["success"]:
|
||||||
await ws_manager.broadcast({"type": "building_updated", "x": data["x"], "y": data["y"], "name": data["name"]})
|
await ws_manager.broadcast({"type": "building_updated", "x": data["x"], "y": data["y"], "name": data["name"]})
|
||||||
database.save_game_state(game_state)
|
database.save_game_state(game_state)
|
||||||
|
else:
|
||||||
|
await websocket.send_json({"type": "error", "message": result["error"]})
|
||||||
|
|
||||||
elif msg_type == "chat":
|
elif msg_type == "chat":
|
||||||
nickname = await ws_manager.get_nickname(websocket)
|
nickname = await ws_manager.get_nickname(websocket)
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ class WebSocketManager:
|
|||||||
self.nickname_to_id: Dict[str, str] = {}
|
self.nickname_to_id: Dict[str, str] = {}
|
||||||
self.game_state = game_state
|
self.game_state = game_state
|
||||||
|
|
||||||
|
# Rebuild nickname-to-ID mapping from game state
|
||||||
|
self._rebuild_nickname_mapping()
|
||||||
|
|
||||||
|
def _rebuild_nickname_mapping(self):
|
||||||
|
"""Rebuild the nickname-to-ID mapping from existing players in game state."""
|
||||||
|
for player_id, player in self.game_state.players.items():
|
||||||
|
self.nickname_to_id[player.nickname] = player_id
|
||||||
|
|
||||||
|
if self.nickname_to_id:
|
||||||
|
logger.info(f"Rebuilt nickname-to-ID mapping for {len(self.nickname_to_id)} players: {list(self.nickname_to_id.keys())}")
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, nickname: str):
|
async def connect(self, websocket: WebSocket, nickname: str):
|
||||||
"""Connect a new player, allowing multiple connections per nickname."""
|
"""Connect a new player, allowing multiple connections per nickname."""
|
||||||
logger.debug(f"Connection attempt from nickname '{nickname}'.")
|
logger.debug(f"Connection attempt from nickname '{nickname}'.")
|
||||||
|
|||||||
+847
-42
@@ -15,7 +15,7 @@ export class GameRenderer {
|
|||||||
// Map of building labels
|
// Map of building labels
|
||||||
|
|
||||||
this.hoveredTile = null;
|
this.hoveredTile = null;
|
||||||
this.cameraPos = { x: 0, y: 50, z: 50 };
|
this.cameraPos = { x: 0, y: 0, z: 0 };
|
||||||
this.cameraZoom = 1; // Re-introduced for proper orthographic zoom
|
this.cameraZoom = 1; // Re-introduced for proper orthographic zoom
|
||||||
|
|
||||||
this.TILE_SIZE = 2;
|
this.TILE_SIZE = 2;
|
||||||
@@ -33,7 +33,8 @@ export class GameRenderer {
|
|||||||
this.camera = new THREE.OrthographicCamera(
|
this.camera = new THREE.OrthographicCamera(
|
||||||
-40, 40, 30, -30, 0.1, 1000
|
-40, 40, 30, -30, 0.1, 1000
|
||||||
);
|
);
|
||||||
this.camera.position.set(0, 50, 50);
|
// Transport Tycoon style isometric view
|
||||||
|
this.camera.position.set(50, 50, 50);
|
||||||
this.camera.lookAt(0, 0, 0);
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
// Create renderer
|
// Create renderer
|
||||||
@@ -43,17 +44,36 @@ export class GameRenderer {
|
|||||||
});
|
});
|
||||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
this.renderer.shadowMap.enabled = true;
|
this.renderer.shadowMap.enabled = true;
|
||||||
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Soft shadows
|
||||||
|
|
||||||
// Add lights
|
// Add lights
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); // Reduced ambient for better shadows
|
||||||
this.scene.add(ambientLight);
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); // Increased intensity
|
||||||
directionalLight.position.set(10, 20, 10);
|
directionalLight.position.set(30, 40, 30); // Higher position for better shadows
|
||||||
directionalLight.castShadow = true;
|
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);
|
this.scene.add(directionalLight);
|
||||||
// Create ground
|
// Create ground
|
||||||
this.createGround();
|
this.createGround();
|
||||||
|
// Create grid
|
||||||
|
this.createGrid();
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
window.addEventListener('resize', () => this.onResize());
|
window.addEventListener('resize', () => this.onResize());
|
||||||
}
|
}
|
||||||
@@ -67,6 +87,49 @@ export class GameRenderer {
|
|||||||
this.scene.add(ground);
|
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) {
|
createTile(x, y, color = 0x90EE90) {
|
||||||
const geometry = new THREE.PlaneGeometry(this.TILE_SIZE - 0.1, this.TILE_SIZE - 0.1);
|
const geometry = new THREE.PlaneGeometry(this.TILE_SIZE - 0.1, this.TILE_SIZE - 0.1);
|
||||||
const material = new THREE.MeshBasicMaterial({
|
const material = new THREE.MeshBasicMaterial({
|
||||||
@@ -83,35 +146,138 @@ export class GameRenderer {
|
|||||||
|
|
||||||
createBuilding(buildingData) {
|
createBuilding(buildingData) {
|
||||||
const { type, x, y, owner_id, name } = buildingData;
|
const { type, x, y, owner_id, name } = buildingData;
|
||||||
// Get building height and color based on type
|
|
||||||
let height = 1;
|
|
||||||
let color = 0x808080;
|
|
||||||
|
|
||||||
if (type.includes('house')) {
|
// Get player color for accents
|
||||||
height = type === 'small_house' ?
|
const playerColor = this.getPlayerColor(owner_id);
|
||||||
2 : type === 'medium_house' ? 3 : 4;
|
|
||||||
color = 0xD2691E;
|
// Determine building orientation based on adjacent roads
|
||||||
} else if (type.includes('shop') || type === 'supermarket' || type === 'mall') {
|
const orientation = this.getBuildingOrientation(x, y, type);
|
||||||
height = 3;
|
|
||||||
color = 0x4169E1;
|
// Create building group
|
||||||
} else if (type.includes('factory')) {
|
const buildingGroup = new THREE.Group();
|
||||||
height = 5;
|
buildingGroup.position.set(x * this.TILE_SIZE, 0, y * this.TILE_SIZE);
|
||||||
color = 0x696969;
|
buildingGroup.userData = { x, y, owner_id, type, name };
|
||||||
} else if (type === 'road') {
|
|
||||||
height = 0.1;
|
// Add foundation/base for all non-road buildings
|
||||||
color = 0x2F4F4F;
|
if (type !== 'road') {
|
||||||
} else if (type === 'park' || type === 'plaza') {
|
const foundation = this.createTileFoundation();
|
||||||
height = 0.5;
|
buildingGroup.add(foundation);
|
||||||
color = 0x32CD32;
|
|
||||||
} else if (type === 'town_hall') {
|
|
||||||
height = 6;
|
|
||||||
color = 0xFFD700;
|
|
||||||
} else if (type === 'power_plant') {
|
|
||||||
height = 8;
|
|
||||||
color = 0xFF4500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create building mesh
|
// 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(
|
const geometry = new THREE.BoxGeometry(
|
||||||
this.TILE_SIZE - 0.2,
|
this.TILE_SIZE - 0.2,
|
||||||
height,
|
height,
|
||||||
@@ -119,18 +285,563 @@ export class GameRenderer {
|
|||||||
);
|
);
|
||||||
const material = new THREE.MeshLambertMaterial({ color: color });
|
const material = new THREE.MeshLambertMaterial({ color: color });
|
||||||
const building = new THREE.Mesh(geometry, material);
|
const building = new THREE.Mesh(geometry, material);
|
||||||
building.position.set(
|
building.position.y = height / 2;
|
||||||
x * this.TILE_SIZE,
|
|
||||||
height / 2,
|
|
||||||
y * this.TILE_SIZE
|
|
||||||
);
|
|
||||||
building.castShadow = true;
|
building.castShadow = true;
|
||||||
building.receiveShadow = true;
|
building.receiveShadow = true;
|
||||||
building.userData = { x, y, owner_id, type, name };
|
|
||||||
|
|
||||||
return building;
|
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) {
|
createCursor(playerId, color) {
|
||||||
const geometry = new THREE.RingGeometry(0.5, 0.7, 16);
|
const geometry = new THREE.RingGeometry(0.5, 0.7, 16);
|
||||||
const material = new THREE.MeshBasicMaterial({
|
const material = new THREE.MeshBasicMaterial({
|
||||||
@@ -144,6 +855,9 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateGameState(gameState) {
|
updateGameState(gameState) {
|
||||||
|
// Store game state for player color access
|
||||||
|
this.gameState = gameState;
|
||||||
|
|
||||||
// Clear existing buildings
|
// Clear existing buildings
|
||||||
this.buildings.forEach(mesh => this.scene.remove(mesh));
|
this.buildings.forEach(mesh => this.scene.remove(mesh));
|
||||||
this.buildings.clear();
|
this.buildings.clear();
|
||||||
@@ -165,6 +879,35 @@ export class GameRenderer {
|
|||||||
const building = this.createBuilding(buildingData);
|
const building = this.createBuilding(buildingData);
|
||||||
this.buildings.set(key, building);
|
this.buildings.set(key, building);
|
||||||
this.scene.add(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) {
|
removeBuilding(x, y) {
|
||||||
@@ -178,8 +921,37 @@ export class GameRenderer {
|
|||||||
updateBuildingName(x, y, name) {
|
updateBuildingName(x, y, name) {
|
||||||
const key = `${x},${y}`;
|
const key = `${x},${y}`;
|
||||||
const building = this.buildings.get(key);
|
const building = this.buildings.get(key);
|
||||||
if (building) {
|
|
||||||
building.userData.name = name;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,20 +1025,53 @@ export class GameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateCameraPosition() {
|
updateCameraPosition() {
|
||||||
|
// Transport Tycoon style isometric positioning
|
||||||
this.camera.position.set(
|
this.camera.position.set(
|
||||||
this.cameraPos.x, this.cameraPos.y, this.cameraPos.z
|
this.cameraPos.x + 50,
|
||||||
|
50,
|
||||||
|
this.cameraPos.z + 50
|
||||||
|
);
|
||||||
|
this.camera.lookAt(
|
||||||
|
this.cameraPos.x,
|
||||||
|
0,
|
||||||
|
this.cameraPos.z
|
||||||
);
|
);
|
||||||
this.camera.lookAt(this.cameraPos.x, 0, this.cameraPos.z - 50);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startRenderLoop() {
|
startRenderLoop() {
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// Update billboard labels to face camera
|
||||||
|
this.updateBillboards();
|
||||||
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
};
|
};
|
||||||
animate();
|
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() {
|
onResize() {
|
||||||
const aspect = window.innerWidth / window.innerHeight;
|
const aspect = window.innerWidth / window.innerHeight;
|
||||||
this.camera.left = -40 * aspect;
|
this.camera.left = -40 * aspect;
|
||||||
|
|||||||
+77
-15
@@ -21,7 +21,21 @@ export class InputHandler {
|
|||||||
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
|
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
|
||||||
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
||||||
this.canvas.addEventListener('wheel', (e) => this.onWheel(e));
|
this.canvas.addEventListener('wheel', (e) => this.onWheel(e));
|
||||||
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
// Prevent context menu on canvas and document
|
||||||
|
this.canvas.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also prevent on document level for better coverage
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
if (e.target === this.canvas || this.canvas.contains(e.target)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
// Keyboard events
|
// Keyboard events
|
||||||
document.addEventListener('keydown', (e) => this.onKeyDown(e));
|
document.addEventListener('keydown', (e) => this.onKeyDown(e));
|
||||||
}
|
}
|
||||||
@@ -56,16 +70,16 @@ export class InputHandler {
|
|||||||
const dx = Math.abs(event.clientX - this.lastMouseX);
|
const dx = Math.abs(event.clientX - this.lastMouseX);
|
||||||
const dy = Math.abs(event.clientY - this.lastMouseY);
|
const dy = Math.abs(event.clientY - this.lastMouseY);
|
||||||
if (dx < dragThreshold && dy < dragThreshold) {
|
if (dx < dragThreshold && dy < dragThreshold) {
|
||||||
// Right click on tile - show context menu
|
// Right click on tile - show context menu with improved hit detection
|
||||||
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
|
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
|
||||||
const building = this.app.gameState.buildings[`${tile.x},${tile.y}`];
|
const buildingInfo = this.findBuildingNearTile(tile.x, tile.y);
|
||||||
|
|
||||||
if (building && building.owner_id === this.app.player.player_id) {
|
if (buildingInfo && buildingInfo.building.owner_id === this.app.player.player_id) {
|
||||||
this.app.uiManager.showContextMenu(
|
this.app.uiManager.showContextMenu(
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
tile.x,
|
buildingInfo.x,
|
||||||
tile.y
|
buildingInfo.y
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,8 +93,17 @@ export class InputHandler {
|
|||||||
this.currentTileX = tile.x;
|
this.currentTileX = tile.x;
|
||||||
this.currentTileY = tile.y;
|
this.currentTileY = tile.y;
|
||||||
|
|
||||||
// Highlight tile
|
// Highlight tile with building detection feedback
|
||||||
this.app.renderer.highlightTile(tile.x, tile.y);
|
const buildingInfo = this.findBuildingNearTile(tile.x, tile.y);
|
||||||
|
if (buildingInfo && buildingInfo.building.owner_id === this.app.player?.player_id) {
|
||||||
|
// Highlight the actual building tile if we found one nearby
|
||||||
|
this.app.renderer.highlightTile(buildingInfo.x, buildingInfo.y);
|
||||||
|
this.canvas.style.cursor = 'pointer'; // Show pointer cursor over owned buildings
|
||||||
|
} else {
|
||||||
|
// Normal tile highlight
|
||||||
|
this.app.renderer.highlightTile(tile.x, tile.y);
|
||||||
|
this.canvas.style.cursor = this.isRightMouseDown ? 'grabbing' : 'default';
|
||||||
|
}
|
||||||
// Send cursor position to server (throttled)
|
// Send cursor position to server (throttled)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastCursorUpdate > this.cursorUpdateThrottle) {
|
if (now - this.lastCursorUpdate > this.cursorUpdateThrottle) {
|
||||||
@@ -91,10 +114,10 @@ export class InputHandler {
|
|||||||
|
|
||||||
// Handle camera panning
|
// Handle camera panning
|
||||||
if (this.isRightMouseDown) {
|
if (this.isRightMouseDown) {
|
||||||
const dx = (event.clientX - this.lastMouseX) * 0.1;
|
const dx = (this.lastMouseX - event.clientX) * 0.3;
|
||||||
const dy = (event.clientY - this.lastMouseY) * 0.1;
|
const dy = (this.lastMouseY - event.clientY) * 0.3;
|
||||||
|
|
||||||
this.app.renderer.moveCamera(-dx, dy);
|
this.app.renderer.moveCamera(dx, dy);
|
||||||
|
|
||||||
this.lastMouseX = event.clientX;
|
this.lastMouseX = event.clientX;
|
||||||
this.lastMouseY = event.clientY;
|
this.lastMouseY = event.clientY;
|
||||||
@@ -119,19 +142,19 @@ export class InputHandler {
|
|||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
this.app.renderer.moveCamera(s, -s); // Move diagonally to pan straight up
|
this.app.renderer.moveCamera(0, -s); // Move straight up in world coordinates
|
||||||
moved = true;
|
moved = true;
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
this.app.renderer.moveCamera(-s, s); // Move diagonally to pan straight down
|
this.app.renderer.moveCamera(0, s); // Move straight down in world coordinates
|
||||||
moved = true;
|
moved = true;
|
||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
this.app.renderer.moveCamera(-s, -s); // Move diagonally to pan straight left
|
this.app.renderer.moveCamera(-s, 0); // Move straight left in world coordinates
|
||||||
moved = true;
|
moved = true;
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
this.app.renderer.moveCamera(s, s); // Move diagonally to pan straight right
|
this.app.renderer.moveCamera(s, 0); // Move straight right in world coordinates
|
||||||
moved = true;
|
moved = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -140,4 +163,43 @@ export class InputHandler {
|
|||||||
event.preventDefault(); // Prevents the browser from scrolling
|
event.preventDefault(); // Prevents the browser from scrolling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findBuildingNearTile(centerX, centerY) {
|
||||||
|
/**
|
||||||
|
* Improved building hit detection with expanded click area.
|
||||||
|
*
|
||||||
|
* Problem: Previously, users had to click exactly on the building's ground tile
|
||||||
|
* which was frustrating and imprecise, especially for 3D buildings.
|
||||||
|
*
|
||||||
|
* Solution: This function checks a 3x3 grid around the clicked point,
|
||||||
|
* making buildings much easier to target by expanding the effective click area.
|
||||||
|
*
|
||||||
|
* Search order: exact tile first, then adjacent tiles, then diagonal tiles
|
||||||
|
* to ensure the closest building is selected when multiple are nearby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// First try exact tile (for precision when needed)
|
||||||
|
let building = this.app.gameState.buildings[`${centerX},${centerY}`];
|
||||||
|
if (building) {
|
||||||
|
return { building, x: centerX, y: centerY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check a 3x3 area around the clicked tile for easier targeting
|
||||||
|
// Check closer tiles first for better UX
|
||||||
|
const searchOrder = [
|
||||||
|
[0, -1], [0, 1], [-1, 0], [1, 0], // Adjacent tiles (distance 1)
|
||||||
|
[-1, -1], [-1, 1], [1, -1], [1, 1] // Diagonal tiles (distance 1.41)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [dx, dy] of searchOrder) {
|
||||||
|
const x = centerX + dx;
|
||||||
|
const y = centerY + dy;
|
||||||
|
building = this.app.gameState.buildings[`${x},${y}`];
|
||||||
|
if (building) {
|
||||||
|
return { building, x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // No building found in area
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ContextMenu extends HTMLElement {
|
|||||||
id="nameInput"
|
id="nameInput"
|
||||||
placeholder="Building name..."
|
placeholder="Building name..."
|
||||||
style="width: 100%; margin-bottom: 8px;"
|
style="width: 100%; margin-bottom: 8px;"
|
||||||
maxlength="30"
|
maxlength="20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -69,6 +69,13 @@ class ContextMenu extends HTMLElement {
|
|||||||
this.currentX = tileX;
|
this.currentX = tileX;
|
||||||
this.currentY = tileY;
|
this.currentY = tileY;
|
||||||
|
|
||||||
|
// Store current building info for editing
|
||||||
|
this.currentBuilding = null;
|
||||||
|
if (this.app && this.app.gameState && this.app.gameState.buildings) {
|
||||||
|
const key = `${tileX},${tileY}`;
|
||||||
|
this.currentBuilding = this.app.gameState.buildings[key];
|
||||||
|
}
|
||||||
|
|
||||||
this.style.left = x + 'px';
|
this.style.left = x + 'px';
|
||||||
this.style.top = y + 'px';
|
this.style.top = y + 'px';
|
||||||
this.style.display = 'block';
|
this.style.display = 'block';
|
||||||
@@ -87,8 +94,10 @@ class ContextMenu extends HTMLElement {
|
|||||||
this.querySelector('#editForm').style.display = 'block';
|
this.querySelector('#editForm').style.display = 'block';
|
||||||
|
|
||||||
const input = this.querySelector('#nameInput');
|
const input = this.querySelector('#nameInput');
|
||||||
input.value = '';
|
// Pre-fill with current building name if it exists
|
||||||
|
input.value = (this.currentBuilding && this.currentBuilding.name) ? this.currentBuilding.name : '';
|
||||||
input.focus();
|
input.focus();
|
||||||
|
input.select(); // Select all text for easy replacement
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
if (confirm('Delete this building?')) {
|
if (confirm('Delete this building?')) {
|
||||||
if (this.app) {
|
if (this.app) {
|
||||||
|
|||||||
Reference in New Issue
Block a user