Uppdate
This commit is contained in:
parent
e9585f8570
commit
1640101f65
@ -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}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}'.")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user