This commit is contained in:
retoor 2025-10-05 06:13:39 +02:00
parent e9585f8570
commit 1640101f65
6 changed files with 243 additions and 16 deletions

View File

@ -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}

View File

@ -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)

View File

@ -14,6 +14,17 @@ class WebSocketManager:
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."""
logger.debug(f"Connection attempt from nickname '{nickname}'.")

View File

@ -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) {
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;

View File

@ -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
// 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
}
}

View File

@ -22,7 +22,7 @@ class ContextMenu extends HTMLElement {
id="nameInput"
placeholder="Building name..."
style="width: 100%; margin-bottom: 8px;"
maxlength="30"
maxlength="20"
/>
</div>
`;
@ -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) {