Initial commit.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { GameRenderer } from './GameRenderer.js';
|
||||
import { WebSocketClient } from './WebSocketClient.js';
|
||||
import { InputHandler } from './InputHandler.js';
|
||||
import { UIManager } from './UIManager.js';
|
||||
|
||||
export class App {
|
||||
constructor() {
|
||||
this.renderer = null;
|
||||
this.wsClient = null;
|
||||
this.inputHandler = null;
|
||||
this.uiManager = null;
|
||||
|
||||
this.player = null;
|
||||
this.gameState = {
|
||||
players: {},
|
||||
buildings: {}
|
||||
};
|
||||
|
||||
this.selectedBuildingType = null;
|
||||
this.isPlacingBuilding = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log('Initializing City Builder...');
|
||||
|
||||
// Initialize UI Manager
|
||||
this.uiManager = new UIManager(this);
|
||||
this.uiManager.init();
|
||||
|
||||
// Show login screen
|
||||
this.uiManager.showLoginScreen();
|
||||
}
|
||||
|
||||
async startGame(nickname) {
|
||||
console.log(`Starting game for ${nickname}...`);
|
||||
|
||||
// Hide login, show game UI
|
||||
this.uiManager.hideLoginScreen();
|
||||
this.uiManager.showGameUI();
|
||||
|
||||
// Initialize renderer
|
||||
this.renderer = new GameRenderer();
|
||||
this.renderer.init();
|
||||
|
||||
// Initialize input handler
|
||||
this.inputHandler = new InputHandler(this);
|
||||
this.inputHandler.init();
|
||||
|
||||
// Connect to WebSocket
|
||||
this.wsClient = new WebSocketClient(this);
|
||||
await this.wsClient.connect(nickname);
|
||||
|
||||
// Start render loop
|
||||
this.renderer.startRenderLoop();
|
||||
}
|
||||
|
||||
onPlayerInit(playerData, gameState) {
|
||||
console.log('Player initialized:', playerData);
|
||||
this.player = playerData;
|
||||
this.gameState = gameState;
|
||||
|
||||
// Update UI
|
||||
this.uiManager.updateStats(this.player);
|
||||
this.uiManager.updateBuildingToolbox(this.player);
|
||||
|
||||
// Render initial state
|
||||
this.renderer.updateGameState(gameState);
|
||||
}
|
||||
|
||||
onGameStateUpdate(state) {
|
||||
this.gameState = state;
|
||||
this.renderer.updateGameState(state);
|
||||
|
||||
// Update own player stats
|
||||
if (this.player && state.players[this.player.player_id]) {
|
||||
this.player = state.players[this.player.player_id];
|
||||
this.uiManager.updateStats(this.player);
|
||||
this.uiManager.updateBuildingToolbox(this.player);
|
||||
}
|
||||
}
|
||||
|
||||
onCursorMove(playerId, x, y) {
|
||||
this.renderer.updateCursor(playerId, x, y);
|
||||
}
|
||||
|
||||
onBuildingPlaced(building) {
|
||||
console.log('Building placed:', building);
|
||||
this.renderer.addBuilding(building);
|
||||
}
|
||||
|
||||
onBuildingRemoved(x, y) {
|
||||
console.log('Building removed at:', x, y);
|
||||
this.renderer.removeBuilding(x, y);
|
||||
}
|
||||
|
||||
onBuildingUpdated(x, y, name) {
|
||||
console.log('Building updated:', x, y, name);
|
||||
this.renderer.updateBuildingName(x, y, name);
|
||||
}
|
||||
|
||||
onPlayerJoined(playerId, nickname) {
|
||||
console.log('Player joined:', nickname);
|
||||
this.uiManager.addChatMessage('system', `${nickname} joined the game`);
|
||||
}
|
||||
|
||||
onPlayerLeft(playerId, nickname) {
|
||||
console.log('Player left:', nickname);
|
||||
this.uiManager.addChatMessage('system', `${nickname} left the game`);
|
||||
this.renderer.removeCursor(playerId);
|
||||
}
|
||||
|
||||
onChatMessage(nickname, message, timestamp) {
|
||||
this.uiManager.addChatMessage(nickname, message, timestamp);
|
||||
}
|
||||
|
||||
onError(message) {
|
||||
console.error('Error:', message);
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// Player actions
|
||||
selectBuilding(buildingType) {
|
||||
this.selectedBuildingType = buildingType;
|
||||
this.isPlacingBuilding = true;
|
||||
console.log('Selected building:', buildingType);
|
||||
}
|
||||
|
||||
placeBuilding(x, y) {
|
||||
if (!this.selectedBuildingType) return;
|
||||
|
||||
console.log('Placing building:', this.selectedBuildingType, 'at', x, y);
|
||||
this.wsClient.placeBuilding(this.selectedBuildingType, x, y);
|
||||
}
|
||||
|
||||
removeBuilding(x, y) {
|
||||
console.log('Removing building at:', x, y);
|
||||
this.wsClient.removeBuilding(x, y);
|
||||
}
|
||||
|
||||
editBuilding(x, y, name) {
|
||||
console.log('Editing building at:', x, y, 'new name:', name);
|
||||
this.wsClient.editBuilding(x, y, name);
|
||||
}
|
||||
|
||||
sendChatMessage(message) {
|
||||
const timestamp = new Date().toTimeString().slice(0, 5);
|
||||
this.wsClient.sendChat(message, timestamp);
|
||||
}
|
||||
|
||||
sendCursorPosition(x, y) {
|
||||
this.wsClient.sendCursorMove(x, y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
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: 50, z: 50 };
|
||||
this.cameraZoom = 1;
|
||||
|
||||
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
|
||||
);
|
||||
this.camera.position.set(0, 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;
|
||||
|
||||
// Add lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(10, 20, 10);
|
||||
directionalLight.castShadow = true;
|
||||
this.scene.add(directionalLight);
|
||||
|
||||
// Create ground
|
||||
this.createGround();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 building height and color based on type
|
||||
let height = 1;
|
||||
let color = 0x808080;
|
||||
|
||||
if (type.includes('house')) {
|
||||
height = type === 'small_house' ? 2 : type === 'medium_house' ? 3 : 4;
|
||||
color = 0xD2691E;
|
||||
} else if (type.includes('shop') || type === 'supermarket' || type === 'mall') {
|
||||
height = 3;
|
||||
color = 0x4169E1;
|
||||
} else if (type.includes('factory')) {
|
||||
height = 5;
|
||||
color = 0x696969;
|
||||
} else if (type === 'road') {
|
||||
height = 0.1;
|
||||
color = 0x2F4F4F;
|
||||
} else if (type === 'park' || type === 'plaza') {
|
||||
height = 0.5;
|
||||
color = 0x32CD32;
|
||||
} else if (type === 'town_hall') {
|
||||
height = 6;
|
||||
color = 0xFFD700;
|
||||
} else if (type === 'power_plant') {
|
||||
height = 8;
|
||||
color = 0xFF4500;
|
||||
}
|
||||
|
||||
// Create building mesh
|
||||
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.set(
|
||||
x * this.TILE_SIZE,
|
||||
height / 2,
|
||||
y * this.TILE_SIZE
|
||||
);
|
||||
building.castShadow = true;
|
||||
building.receiveShadow = true;
|
||||
building.userData = { x, y, owner_id, type, name };
|
||||
|
||||
return building;
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
building.userData.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
this.cameraZoom = Math.max(0.5, Math.min(2, this.cameraZoom + delta));
|
||||
this.updateCameraPosition();
|
||||
}
|
||||
|
||||
updateCameraPosition() {
|
||||
this.camera.position.set(
|
||||
this.cameraPos.x,
|
||||
this.cameraPos.y * this.cameraZoom,
|
||||
this.cameraPos.z * this.cameraZoom
|
||||
);
|
||||
this.camera.lookAt(this.cameraPos.x, 0, 0);
|
||||
}
|
||||
|
||||
startRenderLoop() {
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
animate();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
export class InputHandler {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.canvas = null;
|
||||
|
||||
this.isRightMouseDown = false;
|
||||
this.lastMouseX = 0;
|
||||
this.lastMouseY = 0;
|
||||
|
||||
this.currentTileX = null;
|
||||
this.currentTileY = null;
|
||||
|
||||
this.cursorUpdateThrottle = 100; // ms
|
||||
this.lastCursorUpdate = 0;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.canvas = document.getElementById('gameCanvas');
|
||||
|
||||
// Mouse events
|
||||
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
|
||||
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
|
||||
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
||||
this.canvas.addEventListener('wheel', (e) => this.onWheel(e));
|
||||
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Keyboard events
|
||||
document.addEventListener('keydown', (e) => this.onKeyDown(e));
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
if (event.button === 2) { // Right mouse button
|
||||
this.isRightMouseDown = true;
|
||||
this.lastMouseX = event.clientX;
|
||||
this.lastMouseY = event.clientY;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
} else if (event.button === 0) { // Left mouse button
|
||||
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
|
||||
|
||||
if (this.app.isPlacingBuilding && this.app.selectedBuildingType) {
|
||||
// Place building
|
||||
this.app.placeBuilding(tile.x, tile.y);
|
||||
this.app.isPlacingBuilding = false;
|
||||
this.app.selectedBuildingType = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
if (event.button === 2) { // Right mouse button
|
||||
this.isRightMouseDown = false;
|
||||
this.canvas.style.cursor = 'default';
|
||||
|
||||
// Check if click (not drag)
|
||||
const dragThreshold = 5;
|
||||
const dx = Math.abs(event.clientX - this.lastMouseX);
|
||||
const dy = Math.abs(event.clientY - this.lastMouseY);
|
||||
|
||||
if (dx < dragThreshold && dy < dragThreshold) {
|
||||
// Right click on tile - show context menu
|
||||
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
|
||||
const building = this.app.gameState.buildings[`${tile.x},${tile.y}`];
|
||||
|
||||
if (building && building.owner_id === this.app.player.player_id) {
|
||||
this.app.uiManager.showContextMenu(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
tile.x,
|
||||
tile.y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
// Update tile position
|
||||
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
|
||||
|
||||
if (tile.x !== this.currentTileX || tile.y !== this.currentTileY) {
|
||||
this.currentTileX = tile.x;
|
||||
this.currentTileY = tile.y;
|
||||
|
||||
// Highlight tile
|
||||
this.app.renderer.highlightTile(tile.x, tile.y);
|
||||
|
||||
// Send cursor position to server (throttled)
|
||||
const now = Date.now();
|
||||
if (now - this.lastCursorUpdate > this.cursorUpdateThrottle) {
|
||||
this.app.sendCursorPosition(tile.x, tile.y);
|
||||
this.lastCursorUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle camera panning
|
||||
if (this.isRightMouseDown) {
|
||||
const dx = (event.clientX - this.lastMouseX) * 0.1;
|
||||
const dy = (event.clientY - this.lastMouseY) * 0.1;
|
||||
|
||||
this.app.renderer.moveCamera(-dx, dy);
|
||||
|
||||
this.lastMouseX = event.clientX;
|
||||
this.lastMouseY = event.clientY;
|
||||
}
|
||||
}
|
||||
|
||||
onWheel(event) {
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY > 0 ? -0.1 : 0.1;
|
||||
this.app.renderer.zoomCamera(delta);
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
// ESC to cancel building placement
|
||||
if (event.key === 'Escape') {
|
||||
this.app.isPlacingBuilding = false;
|
||||
this.app.selectedBuildingType = null;
|
||||
this.app.uiManager.hideContextMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import './components/LoginScreen.js';
|
||||
import './components/StatsDisplay.js';
|
||||
import './components/BuildingToolbox.js';
|
||||
import './components/ChatBox.js';
|
||||
import './components/ContextMenu.js';
|
||||
|
||||
export class UIManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.loginScreen = null;
|
||||
this.statsDisplay = null;
|
||||
this.buildingToolbox = null;
|
||||
this.chatBox = null;
|
||||
this.contextMenu = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loginScreen = document.getElementById('loginScreen');
|
||||
this.statsDisplay = document.getElementById('statsDisplay');
|
||||
this.buildingToolbox = document.getElementById('buildingToolbox');
|
||||
this.chatBox = document.getElementById('chatBox');
|
||||
this.contextMenu = document.getElementById('contextMenu');
|
||||
|
||||
// Set app reference in components
|
||||
this.loginScreen.app = this.app;
|
||||
this.buildingToolbox.app = this.app;
|
||||
this.chatBox.app = this.app;
|
||||
this.contextMenu.app = this.app;
|
||||
}
|
||||
|
||||
showLoginScreen() {
|
||||
this.loginScreen.style.display = 'flex';
|
||||
}
|
||||
|
||||
hideLoginScreen() {
|
||||
this.loginScreen.style.display = 'none';
|
||||
}
|
||||
|
||||
showGameUI() {
|
||||
document.getElementById('gameUI').style.display = 'block';
|
||||
}
|
||||
|
||||
updateStats(player) {
|
||||
if (this.statsDisplay) {
|
||||
this.statsDisplay.setAttribute('money', player.money);
|
||||
this.statsDisplay.setAttribute('population', player.population);
|
||||
this.statsDisplay.setAttribute('nickname', player.nickname);
|
||||
}
|
||||
}
|
||||
|
||||
updateBuildingToolbox(player) {
|
||||
if (this.buildingToolbox) {
|
||||
this.buildingToolbox.setAttribute('player-money', player.money);
|
||||
this.buildingToolbox.setAttribute('player-population', player.population);
|
||||
}
|
||||
}
|
||||
|
||||
addChatMessage(nickname, message, timestamp) {
|
||||
if (this.chatBox) {
|
||||
this.chatBox.addMessage(nickname, message, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
showContextMenu(x, y, tileX, tileY) {
|
||||
if (this.contextMenu) {
|
||||
this.contextMenu.show(x, y, tileX, tileY);
|
||||
}
|
||||
}
|
||||
|
||||
hideContextMenu() {
|
||||
if (this.contextMenu) {
|
||||
this.contextMenu.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
export class WebSocketClient {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
}
|
||||
|
||||
async connect(nickname) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/${encodeURIComponent(nickname)}`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(JSON.parse(event.data));
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.attemptReconnect(nickname);
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
attemptReconnect(nickname) {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
console.log(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
|
||||
setTimeout(() => this.connect(nickname), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'init':
|
||||
this.app.onPlayerInit(data.player, data.game_state);
|
||||
break;
|
||||
|
||||
case 'game_state_update':
|
||||
this.app.onGameStateUpdate(data.state);
|
||||
break;
|
||||
|
||||
case 'cursor_move':
|
||||
this.app.onCursorMove(data.player_id, data.x, data.y);
|
||||
break;
|
||||
|
||||
case 'building_placed':
|
||||
this.app.onBuildingPlaced(data.building);
|
||||
break;
|
||||
|
||||
case 'building_removed':
|
||||
this.app.onBuildingRemoved(data.x, data.y);
|
||||
break;
|
||||
|
||||
case 'building_updated':
|
||||
this.app.onBuildingUpdated(data.x, data.y, data.name);
|
||||
break;
|
||||
|
||||
case 'player_joined':
|
||||
this.app.onPlayerJoined(data.player_id, data.nickname);
|
||||
break;
|
||||
|
||||
case 'player_left':
|
||||
this.app.onPlayerLeft(data.player_id, data.nickname);
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
this.app.onChatMessage(data.nickname, data.message, data.timestamp);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.app.onError(data.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
sendCursorMove(x, y) {
|
||||
this.send({
|
||||
type: 'cursor_move',
|
||||
x: x,
|
||||
y: y
|
||||
});
|
||||
}
|
||||
|
||||
placeBuilding(buildingType, x, y) {
|
||||
this.send({
|
||||
type: 'place_building',
|
||||
building_type: buildingType,
|
||||
x: x,
|
||||
y: y
|
||||
});
|
||||
}
|
||||
|
||||
removeBuilding(x, y) {
|
||||
this.send({
|
||||
type: 'remove_building',
|
||||
x: x,
|
||||
y: y
|
||||
});
|
||||
}
|
||||
|
||||
editBuilding(x, y, name) {
|
||||
this.send({
|
||||
type: 'edit_building',
|
||||
x: x,
|
||||
y: y,
|
||||
name: name
|
||||
});
|
||||
}
|
||||
|
||||
sendChat(message, timestamp) {
|
||||
this.send({
|
||||
type: 'chat',
|
||||
message: message,
|
||||
timestamp: timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
class BuildingToolbox extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['player-money', 'player-population'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.app = null;
|
||||
this.buildings = [
|
||||
{ type: 'small_house', name: 'Small House', cost: 5000, income: -50, pop: 10 },
|
||||
{ type: 'medium_house', name: 'Medium House', cost: 12000, income: -120, pop: 25 },
|
||||
{ type: 'large_house', name: 'Large House', cost: 25000, income: -250, pop: 50 },
|
||||
{ type: 'small_shop', name: 'Small Shop', cost: 8000, income: 100, pop: -5, req: 20 },
|
||||
{ type: 'supermarket', name: 'Supermarket', cost: 25000, income: 300, pop: -15, req: 50 },
|
||||
{ type: 'mall', name: 'Shopping Mall', cost: 80000, income: 800, pop: -40, req: 100 },
|
||||
{ type: 'small_factory', name: 'Small Factory', cost: 15000, income: 200, pop: -20 },
|
||||
{ type: 'large_factory', name: 'Large Factory', cost: 50000, income: 500, pop: -50 },
|
||||
{ type: 'road', name: 'Road', cost: 500, income: 0, pop: 0 },
|
||||
{ type: 'park', name: 'Park', cost: 3000, income: -20, pop: 5 },
|
||||
{ type: 'plaza', name: 'Plaza', cost: 8000, income: -40, pop: 10 },
|
||||
{ type: 'town_hall', name: 'Town Hall', cost: 50000, income: -100, pop: 100 },
|
||||
{ type: 'power_plant', name: 'Power Plant', cost: 100000, income: -500, pop: -30 }
|
||||
];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const money = parseInt(this.getAttribute('player-money') || '0');
|
||||
const population = parseInt(this.getAttribute('player-population') || '0');
|
||||
|
||||
this.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 10px; font-size: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 5px;">
|
||||
Buildings
|
||||
</div>
|
||||
<div style="max-height: calc(100vh - 200px); overflow-y: auto;">
|
||||
${this.buildings.map(building => {
|
||||
const canAfford = money >= building.cost;
|
||||
const meetsReq = !building.req || population >= building.req;
|
||||
const enabled = canAfford && meetsReq;
|
||||
|
||||
return `
|
||||
<div
|
||||
class="building-item"
|
||||
data-type="${building.type}"
|
||||
style="
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-light);
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: ${enabled ? 'pointer' : 'not-allowed'};
|
||||
opacity: ${enabled ? '1' : '0.5'};
|
||||
"
|
||||
>
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">
|
||||
${building.name}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #90EE90;">
|
||||
Cost: $${building.cost.toLocaleString()}
|
||||
</div>
|
||||
<div style="font-size: 11px; margin-top: 2px;">
|
||||
${building.income !== 0 ? `Income: $${building.income}/tick` : ''}
|
||||
${building.pop !== 0 ? `Pop: ${building.pop > 0 ? '+' : ''}${building.pop}` : ''}
|
||||
${building.req ? `(Req: ${building.req} pop)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add click handlers
|
||||
this.querySelectorAll('.building-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const type = item.dataset.type;
|
||||
const building = this.buildings.find(b => b.type === type);
|
||||
|
||||
if (money >= building.cost && (!building.req || population >= building.req)) {
|
||||
if (this.app) {
|
||||
this.app.selectBuilding(type);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('building-toolbox', BuildingToolbox);
|
||||
@@ -0,0 +1,66 @@
|
||||
class ChatBox extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.app = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div id="chatMessages" style="flex: 1; overflow-y: auto; padding: 5px; font-size: 12px; font-family: 'Courier New', monospace;">
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="chatInput"
|
||||
placeholder="Type message..."
|
||||
style="border: none; border-top: 1px solid var(--border-color); padding: 5px;"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = this.querySelector('#chatInput');
|
||||
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const message = input.value.trim();
|
||||
if (message && this.app) {
|
||||
this.app.sendChatMessage(message);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMessage(nickname, message, timestamp) {
|
||||
const messagesDiv = this.querySelector('#chatMessages');
|
||||
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.style.marginBottom = '3px';
|
||||
|
||||
const time = timestamp || new Date().toTimeString().slice(0, 5);
|
||||
const color = nickname === 'system' ? '#FFD700' : '#87CEEB';
|
||||
|
||||
messageEl.innerHTML = `
|
||||
<span style="color: #666;">${time}</span>
|
||||
<span style="color: ${color}; font-weight: bold;">${nickname}:</span>
|
||||
<span style="color: var(--text-color);">${this.escapeHtml(message)}</span>
|
||||
`;
|
||||
|
||||
messagesDiv.appendChild(messageEl);
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
|
||||
// Keep only last 100 messages
|
||||
while (messagesDiv.children.length > 100) {
|
||||
messagesDiv.removeChild(messagesDiv.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('chat-box', ChatBox);
|
||||
@@ -0,0 +1,114 @@
|
||||
class ContextMenu extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.app = null;
|
||||
this.currentX = null;
|
||||
this.currentY = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<div id="menuItems">
|
||||
<div class="menu-item" data-action="edit" style="padding: 8px; cursor: pointer; border-bottom: 1px solid var(--border-color);">
|
||||
Edit Name
|
||||
</div>
|
||||
<div class="menu-item" data-action="delete" style="padding: 8px; cursor: pointer;">
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
<div id="editForm" style="display: none; padding: 10px;">
|
||||
<input
|
||||
type="text"
|
||||
id="nameInput"
|
||||
placeholder="Building name..."
|
||||
style="width: 100%; margin-bottom: 8px;"
|
||||
maxlength="30"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Menu item clicks
|
||||
this.querySelectorAll('.menu-item').forEach(item => {
|
||||
item.addEventListener('mouseenter', (e) => {
|
||||
e.target.style.background = 'var(--bg-light)';
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', (e) => {
|
||||
e.target.style.background = '';
|
||||
});
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
const action = e.target.dataset.action;
|
||||
this.handleAction(action);
|
||||
});
|
||||
});
|
||||
|
||||
// Edit form
|
||||
const nameInput = this.querySelector('#nameInput');
|
||||
nameInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.submitEdit();
|
||||
}
|
||||
});
|
||||
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.style.display !== 'none' && !this.contains(e.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
show(x, y, tileX, tileY) {
|
||||
this.currentX = tileX;
|
||||
this.currentY = tileY;
|
||||
|
||||
this.style.left = x + 'px';
|
||||
this.style.top = y + 'px';
|
||||
this.style.display = 'block';
|
||||
|
||||
this.querySelector('#menuItems').style.display = 'block';
|
||||
this.querySelector('#editForm').style.display = 'none';
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.style.display = 'none';
|
||||
}
|
||||
|
||||
handleAction(action) {
|
||||
if (action === 'edit') {
|
||||
this.querySelector('#menuItems').style.display = 'none';
|
||||
this.querySelector('#editForm').style.display = 'block';
|
||||
|
||||
const input = this.querySelector('#nameInput');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
} else if (action === 'delete') {
|
||||
if (confirm('Delete this building?')) {
|
||||
if (this.app) {
|
||||
this.app.removeBuilding(this.currentX, this.currentY);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submitEdit() {
|
||||
const input = this.querySelector('#nameInput');
|
||||
const name = input.value.trim();
|
||||
|
||||
if (name && this.app) {
|
||||
this.app.editBuilding(this.currentX, this.currentY, name);
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('context-menu', ContextMenu);
|
||||
@@ -0,0 +1,60 @@
|
||||
class LoginScreen extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.app = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<div style="background: var(--bg-medium); padding: 40px; border: 2px solid var(--border-color); text-align: center;">
|
||||
<h1 style="margin-bottom: 30px; font-size: 32px;">City Builder</h1>
|
||||
<p style="margin-bottom: 20px;">Enter your nickname to start</p>
|
||||
<input
|
||||
type="text"
|
||||
id="nicknameInput"
|
||||
placeholder="Nickname"
|
||||
style="width: 300px; margin-bottom: 20px;"
|
||||
maxlength="20"
|
||||
/>
|
||||
<br>
|
||||
<button class="button" id="startButton">Start Game</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = this.querySelector('#nicknameInput');
|
||||
const button = this.querySelector('#startButton');
|
||||
|
||||
// Enter key to submit
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.startGame();
|
||||
}
|
||||
});
|
||||
|
||||
button.addEventListener('click', () => this.startGame());
|
||||
|
||||
// Focus input
|
||||
setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
|
||||
startGame() {
|
||||
const input = this.querySelector('#nicknameInput');
|
||||
const nickname = input.value.trim();
|
||||
|
||||
if (!nickname) {
|
||||
alert('Please enter a nickname');
|
||||
return;
|
||||
}
|
||||
|
||||
if (nickname.length < 2) {
|
||||
alert('Nickname must be at least 2 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.app) {
|
||||
this.app.startGame(nickname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('login-screen', LoginScreen);
|
||||
@@ -0,0 +1,37 @@
|
||||
class StatsDisplay extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['money', 'population', 'nickname'];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const money = this.getAttribute('money') || '0';
|
||||
const population = this.getAttribute('population') || '0';
|
||||
const nickname = this.getAttribute('nickname') || 'Player';
|
||||
|
||||
const formattedMoney = parseInt(money).toLocaleString();
|
||||
|
||||
this.innerHTML = `
|
||||
<div style="font-size: 14px;">
|
||||
<div style="font-size: 18px; font-weight: bold; margin-bottom: 10px; color: #FFD700;">
|
||||
${nickname}
|
||||
</div>
|
||||
<div style="margin-bottom: 5px;">
|
||||
<span style="color: #90EE90;">$</span> ${formattedMoney}
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: #87CEEB;">👥</span> ${population}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('stats-display', StatsDisplay);
|
||||
Reference in New Issue
Block a user