From 1640101f6533f9e5e8b2b5d8a52275d48b945bc4 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 5 Oct 2025 06:13:39 +0200 Subject: [PATCH] Uppdate --- server/game_state.py | 8 +- server/main.py | 14 ++- server/websocket_manager.py | 11 +++ static/js/GameRenderer.js | 135 +++++++++++++++++++++++++++- static/js/InputHandler.js | 78 ++++++++++++++-- static/js/components/ContextMenu.js | 13 ++- 6 files changed, 243 insertions(+), 16 deletions(-) diff --git a/server/game_state.py b/server/game_state.py index 36f1b41..2bcb40c 100644 --- a/server/game_state.py +++ b/server/game_state.py @@ -105,7 +105,13 @@ class GameState: if building.owner_id != player_id: 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}'.") return {"success": True} diff --git a/server/main.py b/server/main.py index cddc009..e6fe576 100644 --- a/server/main.py +++ b/server/main.py @@ -22,10 +22,17 @@ def initialize_components(test_db_path=None): """Initialize server components with optional test 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.load_state(database.load_game_state()) + + # Now create WebSocketManager (it will rebuild nickname mapping from loaded players) ws_manager = WebSocketManager(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 @@ -84,8 +91,7 @@ async def lifespan(app: FastAPI): initialize_components() logger.info("Server starting up...") - database.init_db() - game_state.load_state(database.load_game_state()) + # Database and game state are already initialized in initialize_components() # Start the hybrid economy loop task = asyncio.create_task(economy_loop()) @@ -175,6 +181,8 @@ async def handle_message(websocket: WebSocket, data: dict): if result["success"]: await ws_manager.broadcast({"type": "building_updated", "x": data["x"], "y": data["y"], "name": data["name"]}) database.save_game_state(game_state) + else: + await websocket.send_json({"type": "error", "message": result["error"]}) elif msg_type == "chat": nickname = await ws_manager.get_nickname(websocket) diff --git a/server/websocket_manager.py b/server/websocket_manager.py index 758c099..e4434d1 100644 --- a/server/websocket_manager.py +++ b/server/websocket_manager.py @@ -13,6 +13,17 @@ class WebSocketManager: self.player_nicknames: Dict[str, str] = {} self.nickname_to_id: Dict[str, str] = {} 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): """Connect a new player, allowing multiple connections per nickname.""" diff --git a/static/js/GameRenderer.js b/static/js/GameRenderer.js index 5b37cc6..291478e 100644 --- a/static/js/GameRenderer.js +++ b/static/js/GameRenderer.js @@ -186,6 +186,13 @@ export class GameRenderer { } 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; } @@ -766,6 +773,75 @@ export class GameRenderer { return group; } + createBuildingLabel(name, playerColor) { + // Create canvas for text rendering + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + // Much larger canvas for better visibility + canvas.width = 1024; + canvas.height = 256; + + // Clear background + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Much bigger text for better visibility + const fontSize = 120; + ctx.font = `bold ${fontSize}px Arial`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Draw colored background + const padding = 32; + const textWidth = ctx.measureText(name).width; + const bgWidth = Math.max(textWidth + padding * 2, 300); + const bgHeight = fontSize + padding * 2; + + // Fill with player color + ctx.fillStyle = `rgb(${Math.floor(playerColor.r * 255)}, ${Math.floor(playerColor.g * 255)}, ${Math.floor(playerColor.b * 255)})`; + ctx.fillRect( + (canvas.width - bgWidth) / 2, + (canvas.height - bgHeight) / 2, + bgWidth, + bgHeight + ); + + // Draw black text + ctx.fillStyle = 'black'; + ctx.fillText(name, canvas.width / 2, canvas.height / 2); + + // Create texture and material + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + side: THREE.DoubleSide, + depthTest: false // Always render on top + }); + + // Much larger geometry - 2x bigger than before + const geometry = new THREE.PlaneGeometry(8, 2); + const labelMesh = new THREE.Mesh(geometry, material); + + // Position above building + labelMesh.position.set(0, 4.5, 0); + + // Make labels always face camera (billboard effect) and appear horizontal + // This will be updated in the render loop to always face the camera + labelMesh.userData.isBillboard = true; + + // Initial rotation to face the isometric camera + // Camera is positioned at (cameraPos.x + 50, 50, cameraPos.z + 50) + // We want labels to face northeast towards camera but remain horizontal + labelMesh.rotation.y = -Math.PI / 4; // Face camera direction + labelMesh.rotation.x = 0; // Keep horizontal (no tilt) + + // Mark as building label + labelMesh.userData.isBuildingLabel = true; + + return labelMesh; + } + createCursor(playerId, color) { const geometry = new THREE.RingGeometry(0.5, 0.7, 16); const material = new THREE.MeshBasicMaterial({ @@ -845,8 +921,37 @@ export class GameRenderer { updateBuildingName(x, y, name) { const key = `${x},${y}`; 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); } } @@ -936,11 +1041,37 @@ export class GameRenderer { startRenderLoop() { const animate = () => { requestAnimationFrame(animate); + + // Update billboard labels to face camera + this.updateBillboards(); + this.renderer.render(this.scene, this.camera); }; animate(); } + updateBillboards() { + // Make every billboard label face the camera horizontally (yaw only) + const camPos = this.camera.position; + this.buildings.forEach(building => { + building.traverse(child => { + if (child.userData && child.userData.isBillboard) { + // Get world position of label + const worldPos = new THREE.Vector3(); + child.getWorldPosition(worldPos); + + // Compute angle to camera on XZ plane + const dx = camPos.x - worldPos.x; + const dz = camPos.z - worldPos.z; + const angle = Math.atan2(dx, dz); // radians + + // Apply rotation: yaw = angle, keep flat (no pitch/roll) + child.rotation.set(0, angle, 0); + } + }); + }); + } + onResize() { const aspect = window.innerWidth / window.innerHeight; this.camera.left = -40 * aspect; diff --git a/static/js/InputHandler.js b/static/js/InputHandler.js index 7625dc6..654d4e8 100644 --- a/static/js/InputHandler.js +++ b/static/js/InputHandler.js @@ -21,7 +21,21 @@ export class InputHandler { 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()); + // 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 document.addEventListener('keydown', (e) => this.onKeyDown(e)); } @@ -56,16 +70,16 @@ export class InputHandler { 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 + // Right click on tile - show context menu with improved hit detection 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( event.clientX, event.clientY, - tile.x, - tile.y + buildingInfo.x, + buildingInfo.y ); } } @@ -79,8 +93,17 @@ export class InputHandler { this.currentTileX = tile.x; this.currentTileY = tile.y; - // Highlight tile - this.app.renderer.highlightTile(tile.x, tile.y); + // Highlight tile with building detection feedback + 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) const now = Date.now(); if (now - this.lastCursorUpdate > this.cursorUpdateThrottle) { @@ -140,4 +163,43 @@ export class InputHandler { 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 + } } diff --git a/static/js/components/ContextMenu.js b/static/js/components/ContextMenu.js index cc13bdc..0c1c341 100644 --- a/static/js/components/ContextMenu.js +++ b/static/js/components/ContextMenu.js @@ -22,7 +22,7 @@ class ContextMenu extends HTMLElement { id="nameInput" placeholder="Building name..." style="width: 100%; margin-bottom: 8px;" - maxlength="30" + maxlength="20" /> `; @@ -69,6 +69,13 @@ class ContextMenu extends HTMLElement { this.currentX = tileX; 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.top = y + 'px'; this.style.display = 'block'; @@ -87,8 +94,10 @@ class ContextMenu extends HTMLElement { this.querySelector('#editForm').style.display = 'block'; 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.select(); // Select all text for easy replacement } else if (action === 'delete') { if (confirm('Delete this building?')) { if (this.app) {