Compare commits

..

No commits in common. "a547b6ede1f4801fdb1f87a536770bfb266aec13" and "416cc4511b7af33abfc43a007f0021429ae7a3cd" have entirely different histories.

12 changed files with 340 additions and 430 deletions

View File

@ -2,8 +2,6 @@ import sqlite3
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List from typing import Optional, Dict, List
from server.logger import logger
import time
class Database: class Database:
"""Handles all database operations""" """Handles all database operations"""
@ -20,8 +18,8 @@ class Database:
def init_db(self): def init_db(self):
"""Initialize database tables""" """Initialize database tables"""
logger.info(f"Initializing database at {self.db_path}...")
with self._get_connection() as conn: with self._get_connection() as conn:
# Players table
conn.execute(''' conn.execute('''
CREATE TABLE IF NOT EXISTS players ( CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY, player_id TEXT PRIMARY KEY,
@ -32,6 +30,8 @@ class Database:
last_online REAL NOT NULL last_online REAL NOT NULL
) )
''') ''')
# Buildings table
conn.execute(''' conn.execute('''
CREATE TABLE IF NOT EXISTS buildings ( CREATE TABLE IF NOT EXISTS buildings (
x INTEGER NOT NULL, x INTEGER NOT NULL,
@ -44,65 +44,78 @@ class Database:
FOREIGN KEY (owner_id) REFERENCES players (player_id) FOREIGN KEY (owner_id) REFERENCES players (player_id)
) )
''') ''')
conn.commit() conn.commit()
logger.info("Database initialized successfully.")
def save_game_state(self, game_state): def save_game_state(self, game_state):
"""Save complete game state to database""" """Save complete game state to database"""
logger.debug(f"Saving game state to database... ({len(game_state.players)} players, {len(game_state.buildings)} buildings)")
start_time = time.perf_counter()
with self._get_connection() as conn: with self._get_connection() as conn:
player_data = [ # Save players using INSERT OR REPLACE to handle existing nicknames
(p.player_id, p.nickname, p.money, p.population, p.color, p.last_online) for player in game_state.players.values():
for p in game_state.players.values() conn.execute('''
]
if player_data:
conn.executemany('''
INSERT OR REPLACE INTO players (player_id, nickname, money, population, color, last_online) INSERT OR REPLACE INTO players (player_id, nickname, money, population, color, last_online)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
''', player_data) ''', (
player.player_id,
cursor = conn.execute('SELECT x, y FROM buildings') player.nickname,
db_coords = {(row['x'], row['y']) for row in cursor} player.money,
gs_coords = set(game_state.buildings.keys()) player.population,
player.color,
coords_to_delete = db_coords - gs_coords player.last_online
if coords_to_delete: ))
conn.executemany('DELETE FROM buildings WHERE x = ? AND y = ?', list(coords_to_delete))
# Save buildings
building_data = [ conn.execute('DELETE FROM buildings')
(b.x, b.y, b.building_type.value, b.owner_id, b.name, b.placed_at)
for b in game_state.buildings.values() for building in game_state.buildings.values():
] conn.execute('''
if building_data: INSERT INTO buildings (x, y, type, owner_id, name, placed_at)
conn.executemany('''
INSERT OR REPLACE INTO buildings (x, y, type, owner_id, name, placed_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
''', building_data) ''', (
building.x,
building.y,
building.building_type.value,
building.owner_id,
building.name,
building.placed_at
))
conn.commit() conn.commit()
duration = time.perf_counter() - start_time
logger.debug(f"Game state saved to database in {duration:.4f} seconds.")
def load_game_state(self) -> dict: def load_game_state(self) -> dict:
"""Load complete game state from database""" """Load complete game state from database"""
logger.info("Loading game state from database...")
start_time = time.perf_counter()
try: try:
with self._get_connection() as conn: with self._get_connection() as conn:
players, buildings = [], [] # Load players
players = []
cursor = conn.execute('SELECT * FROM players') cursor = conn.execute('SELECT * FROM players')
for row in cursor: for row in cursor:
players.append(dict(row)) players.append({
"player_id": row["player_id"],
"nickname": row["nickname"],
"money": row["money"],
"population": row["population"],
"color": row["color"],
"last_online": row["last_online"]
})
# Load buildings
buildings = []
cursor = conn.execute('SELECT * FROM buildings') cursor = conn.execute('SELECT * FROM buildings')
for row in cursor: for row in cursor:
buildings.append(dict(row)) buildings.append({
"x": row["x"],
"y": row["y"],
"type": row["type"],
"owner_id": row["owner_id"],
"name": row["name"],
"placed_at": row["placed_at"]
})
duration = time.perf_counter() - start_time return {
logger.info(f"Loaded {len(players)} players and {len(buildings)} buildings in {duration:.4f} seconds.") "players": players,
return {"players": players, "buildings": buildings} "buildings": buildings
}
except Exception as e: except Exception as e:
logger.error(f"Error loading game state: {e}", exc_info=True) print(f"Error loading game state: {e}")
return {"players": [], "buildings": []} return {"players": [], "buildings": []}

View File

@ -1,8 +1,6 @@
from server.game_state import GameState from server.game_state import GameState
from server.models import BUILDING_CONFIGS, BuildingType from server.models import BUILDING_CONFIGS, BuildingType
import time import time
from collections import defaultdict
from server.logger import logger
class EconomyEngine: class EconomyEngine:
"""Handles all economy calculations and ticks""" """Handles all economy calculations and ticks"""
@ -11,45 +9,55 @@ class EconomyEngine:
self.game_state = game_state self.game_state = game_state
def tick(self): def tick(self):
""" """Process one economy tick for all players"""
Process one economy tick for all players using an efficient aggregate calculation. current_time = time.time()
"""
logger.debug("Processing economy tick...")
start_time = time.perf_counter()
player_money_deltas = defaultdict(int) for player in self.game_state.players.values():
player_total_population = defaultdict(int) # Calculate power factor (10% if offline, 100% if online)
time_diff = current_time - player.last_online
# 1. Single pass over all buildings to aggregate their effects if player.is_online:
for building in self.game_state.buildings.values(): power_factor = 1.0
config = BUILDING_CONFIGS[building.building_type] else:
owner_id = building.owner_id power_factor = 0.1
# Process player economy
self._process_player_economy(player, power_factor)
def _process_player_economy(self, player, power_factor: float):
"""Process economy for a single player"""
total_income = 0
total_population = 0
# Get all player buildings
buildings = self.game_state.get_player_buildings(player.player_id)
for building in buildings:
config = BUILDING_CONFIGS[building.building_type]
# Calculate base income
base_income = config.income base_income = config.income
# Apply connectivity bonus for income-generating buildings
if base_income > 0: if base_income > 0:
zone_size = self.game_state.get_building_zone_size(building.x, building.y) zone_size = self.game_state.get_building_zone_size(building.x, building.y)
connectivity_bonus = 1.0 + (zone_size * 0.05) connectivity_bonus = 1.0 + (zone_size * 0.05) # 5% per road in zone
base_income = int(base_income * connectivity_bonus) base_income = int(base_income * connectivity_bonus)
player_money_deltas[owner_id] += base_income # Add to totals
player_total_population[owner_id] += config.population total_income += base_income
total_population += config.population
# 2. Apply the aggregated deltas to each player
for player_id, player in self.game_state.players.items():
power_factor = 1.0 if player.is_online else 0.1
money_delta = player_money_deltas.get(player_id, 0)
player.money += int(money_delta * power_factor)
total_population = player_total_population.get(player_id, 0)
player.population = max(0, total_population)
if player.money < -100000:
player.money = -100000
duration = time.perf_counter() - start_time # Apply power factor
logger.debug(f"Economy tick processed in {duration:.4f} seconds for {len(self.game_state.players)} players.") total_income = int(total_income * power_factor)
# Update player stats
player.money += total_income
player.population = max(0, total_population)
# Prevent negative money (but allow debt for realism)
if player.money < -100000:
player.money = -100000
def calculate_building_stats(self, player_id: str, building_type: BuildingType) -> dict: def calculate_building_stats(self, player_id: str, building_type: BuildingType) -> dict:
"""Calculate what a building would produce for a player""" """Calculate what a building would produce for a player"""
config = BUILDING_CONFIGS[building_type] config = BUILDING_CONFIGS[building_type]

View File

@ -1,7 +1,6 @@
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from server.models import Player, Building, BuildingType, BUILDING_CONFIGS from server.models import Player, Building, BuildingType, BUILDING_CONFIGS
import time import time
from server.logger import logger
class GameState: class GameState:
"""Manages the complete game state""" """Manages the complete game state"""
@ -18,7 +17,6 @@ class GameState:
player = self.players[player_id] player = self.players[player_id]
player.is_online = True player.is_online = True
player.last_online = time.time() player.last_online = time.time()
logger.debug(f"Returning existing player '{nickname}' ({player_id}).")
return player return player
player = Player( player = Player(
@ -27,48 +25,50 @@ class GameState:
last_online=time.time() last_online=time.time()
) )
self.players[player_id] = player self.players[player_id] = player
logger.info(f"Created new player '{nickname}' ({player_id}) with ${player.money} starting money.")
return player return player
def place_building(self, player_id: str, building_type: str, x: int, y: int) -> dict: def place_building(self, player_id: str, building_type: str, x: int, y: int) -> dict:
"""Place a building on the map""" """Place a building on the map"""
logger.debug(f"Attempting to place '{building_type}' at ({x},{y}) for player {player_id}.") # Check if tile is occupied
if (x, y) in self.buildings: if (x, y) in self.buildings:
logger.warning(f"Placement failed for {player_id}: Tile ({x},{y}) already occupied.")
return {"success": False, "error": "Tile already occupied"} return {"success": False, "error": "Tile already occupied"}
# Get player
player = self.players.get(player_id) player = self.players.get(player_id)
if not player: if not player:
logger.error(f"Placement failed: Player {player_id} not found.")
return {"success": False, "error": "Player not found"} return {"success": False, "error": "Player not found"}
# Get building config
try: try:
b_type = BuildingType(building_type) b_type = BuildingType(building_type)
config = BUILDING_CONFIGS[b_type] config = BUILDING_CONFIGS[b_type]
except (ValueError, KeyError): except (ValueError, KeyError):
logger.error(f"Placement failed for {player_id}: Invalid building type '{building_type}'.")
return {"success": False, "error": "Invalid building type"} return {"success": False, "error": "Invalid building type"}
# Check if player can afford
if not player.can_afford(config.cost): if not player.can_afford(config.cost):
logger.debug(f"Placement failed for {player_id}: Cannot afford '{building_type}'. Cost: {config.cost}, Player Money: {player.money}.")
return {"success": False, "error": "Not enough money"} return {"success": False, "error": "Not enough money"}
# Check requirements
if config.requires_population > player.population: if config.requires_population > player.population:
logger.debug(f"Placement failed for {player_id}: '{building_type}' requires {config.requires_population} pop, has {player.population}.")
return {"success": False, "error": f"Requires {config.requires_population} population"} return {"success": False, "error": f"Requires {config.requires_population} population"}
if config.power_required and not self._has_power_plant(player_id): if config.power_required and not self._has_power_plant(player_id):
logger.debug(f"Placement failed for {player_id}: '{building_type}' requires a power plant.")
return {"success": False, "error": "Requires power plant"} return {"success": False, "error": "Requires power plant"}
building = Building(building_type=b_type, x=x, y=y, owner_id=player_id, placed_at=time.time()) # Place building
building = Building(
building_type=b_type,
x=x,
y=y,
owner_id=player_id,
placed_at=time.time()
)
self.buildings[(x, y)] = building self.buildings[(x, y)] = building
player.deduct_money(config.cost) player.deduct_money(config.cost)
logger.info(f"Player {player_id} placed '{building_type}' at ({x},{y}). New balance: ${player.money}.") # Update road network if it's a road
if b_type == BuildingType.ROAD: if b_type == BuildingType.ROAD:
self.road_network.add((x, y)) self.road_network.add((x, y))
self._update_connected_zones() self._update_connected_zones()
@ -77,20 +77,18 @@ class GameState:
def remove_building(self, player_id: str, x: int, y: int) -> dict: def remove_building(self, player_id: str, x: int, y: int) -> dict:
"""Remove a building""" """Remove a building"""
logger.debug(f"Attempting to remove building at ({x},{y}) for player {player_id}.")
building = self.buildings.get((x, y)) building = self.buildings.get((x, y))
if not building: if not building:
logger.warning(f"Removal failed for {player_id}: No building at ({x},{y}).")
return {"success": False, "error": "No building at this location"} return {"success": False, "error": "No building at this location"}
if building.owner_id != player_id: if building.owner_id != player_id:
logger.warning(f"Removal failed for {player_id}: Does not own building at ({x},{y}). Owner is {building.owner_id}.")
return {"success": False, "error": "You don't own this building"} return {"success": False, "error": "You don't own this building"}
# Remove building
del self.buildings[(x, y)] del self.buildings[(x, y)]
logger.info(f"Player {player_id} removed '{building.building_type.value}' from ({x},{y}).")
# Update road network if it was a road
if building.building_type == BuildingType.ROAD: if building.building_type == BuildingType.ROAD:
self.road_network.discard((x, y)) self.road_network.discard((x, y))
self._update_connected_zones() self._update_connected_zones()
@ -100,19 +98,21 @@ class GameState:
def edit_building_name(self, player_id: str, x: int, y: int, name: str) -> dict: def edit_building_name(self, player_id: str, x: int, y: int, name: str) -> dict:
"""Edit building name""" """Edit building name"""
building = self.buildings.get((x, y)) building = self.buildings.get((x, y))
if not building: if not building:
return {"success": False, "error": "No building at this location"} return {"success": False, "error": "No building at this location"}
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 building.name = name
logger.info(f"Player {player_id} renamed building at ({x},{y}) to '{name}'.")
return {"success": True} return {"success": True}
def _has_power_plant(self, player_id: str) -> bool: def _has_power_plant(self, player_id: str) -> bool:
"""Check if player has a power plant""" """Check if player has a power plant"""
for building in self.buildings.values(): for building in self.buildings.values():
if (building.owner_id == player_id and building.building_type == BuildingType.POWER_PLANT): if (building.owner_id == player_id and
building.building_type == BuildingType.POWER_PLANT):
return True return True
return False return False
@ -122,9 +122,6 @@ class GameState:
self.connected_zones = [] self.connected_zones = []
return return
logger.debug("Recalculating road network connected zones...")
start_time = time.perf_counter()
visited = set() visited = set()
self.connected_zones = [] self.connected_zones = []
@ -132,13 +129,19 @@ class GameState:
if road_pos in visited: if road_pos in visited:
continue continue
# Flood fill to find connected zone
zone = set() zone = set()
stack = [road_pos] stack = [road_pos]
while stack: while stack:
pos = stack.pop() pos = stack.pop()
if pos in visited: continue if pos in visited:
continue
visited.add(pos) visited.add(pos)
zone.add(pos) zone.add(pos)
# Check adjacent positions
x, y = pos x, y = pos
for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0)]: for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
adj_pos = (x + dx, y + dy) adj_pos = (x + dx, y + dy)
@ -146,15 +149,14 @@ class GameState:
stack.append(adj_pos) stack.append(adj_pos)
self.connected_zones.append(zone) self.connected_zones.append(zone)
duration = time.perf_counter() - start_time
logger.debug(f"Found {len(self.connected_zones)} road zones in {duration:.4f} seconds.")
def get_building_zone_size(self, x: int, y: int) -> int: def get_building_zone_size(self, x: int, y: int) -> int:
"""Get the size of the connected zone for a building""" """Get the size of the connected zone for a building"""
# Find adjacent roads
for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1)]: for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1)]:
road_pos = (x + dx, y + dy) road_pos = (x + dx, y + dy)
if road_pos in self.road_network: if road_pos in self.road_network:
# Find which zone this road belongs to
for zone in self.connected_zones: for zone in self.connected_zones:
if road_pos in zone: if road_pos in zone:
return len(zone) return len(zone)
@ -176,11 +178,13 @@ class GameState:
if not state: if not state:
return return
# Load players
for player_data in state.get("players", []): for player_data in state.get("players", []):
player = Player(**player_data) player = Player(**player_data)
player.is_online = False # All players start as offline player.is_online = False
self.players[player.player_id] = player self.players[player.player_id] = player
# Load buildings
for building_data in state.get("buildings", []): for building_data in state.get("buildings", []):
building = Building( building = Building(
building_type=BuildingType(building_data["type"]), building_type=BuildingType(building_data["type"]),
@ -196,4 +200,3 @@ class GameState:
self.road_network.add((building.x, building.y)) self.road_network.add((building.x, building.y))
self._update_connected_zones() self._update_connected_zones()
logger.info(f"Loaded state with {len(self.players)} players and {len(self.buildings)} buildings from database.")

View File

@ -1,35 +0,0 @@
import logging
import sys
def setup_logger():
"""Sets up a custom logger for the application."""
# Get the logger
logger = logging.getLogger("CityBuilder")
logger.setLevel(logging.DEBUG)
# Prevent adding duplicate handlers if this function is called multiple times
if logger.hasHandlers():
logger.handlers.clear()
# Create a formatter to define the log message format
formatter = logging.Formatter(
'%(asctime)s - %(name)s - [%(levelname)s] - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Create a handler to output log messages to the console (stdout)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
# Add the handler to the logger
logger.addHandler(stream_handler)
# Optional: To log to a file, uncomment the following lines
# file_handler = logging.FileHandler("server.log")
# file_handler.setFormatter(formatter)
# logger.addHandler(file_handler)
return logger
# Create and export the logger instance
logger = setup_logger()

View File

@ -4,153 +4,157 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pathlib import Path from pathlib import Path
import time
from server.logger import logger
from server.websocket_manager import WebSocketManager from server.websocket_manager import WebSocketManager
from server.game_state import GameState from server.game_state import GameState
from server.economy import EconomyEngine from server.economy import EconomyEngine
from server.database import Database from server.database import Database
# Global instances # Global instances
ws_manager = WebSocketManager()
game_state = GameState() game_state = GameState()
ws_manager = WebSocketManager(game_state)
economy_engine = EconomyEngine(game_state) economy_engine = EconomyEngine(game_state)
database = Database() database = Database()
# --- HYBRID MODEL RE-IMPLEMENTED --- # Background task for economy ticks and persistence
last_economy_tick_time = time.time() async def game_loop():
TICK_INTERVAL = 10 # seconds """Main game loop: economy ticks every 10 seconds, DB save every 10 seconds"""
async def trigger_economy_update_and_save():
"""Triggers an economy tick, broadcasts updates concurrently, saves, and resets the timer."""
global last_economy_tick_time
logger.debug("Triggering full economy update and save cycle...")
start_time = time.perf_counter()
economy_engine.tick()
update_tasks = []
for player_id, player in game_state.players.items():
if player_id in ws_manager.active_connections:
task = ws_manager.send_to_player(player_id, {
"type": "player_stats_update",
"player": player.to_dict()
})
update_tasks.append(task)
if update_tasks:
await asyncio.gather(*update_tasks)
database.save_game_state(game_state)
# Reset the global tick timer after any update
last_economy_tick_time = time.time()
duration = time.perf_counter() - start_time
logger.info(f"Full economy update cycle completed in {duration:.4f} seconds.")
async def economy_loop():
"""Runs periodically to check if a passive economy update is needed."""
global last_economy_tick_time
while True: while True:
# Check if 10 seconds have passed since the last tick (from any source) try:
if time.time() - last_economy_tick_time > TICK_INTERVAL: await asyncio.sleep(10)
logger.info("Triggering timed economy update for idle players.")
await trigger_economy_update_and_save() # Economy tick
economy_engine.tick()
# Check frequently for responsiveness
await asyncio.sleep(1) # Save to database
database.save_game_state(game_state)
# Broadcast state to all players
await ws_manager.broadcast_game_state(game_state.get_state())
except Exception as e:
print(f"Error in game loop: {e}")
# Continue running even if there's an error
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup and shutdown events.""" """Startup and shutdown events"""
logger.info("Server starting up...") # Startup
database.init_db() database.init_db()
game_state.load_state(database.load_game_state()) game_state.load_state(database.load_game_state())
# Start the hybrid economy loop # Start game loop
task = asyncio.create_task(economy_loop()) task = asyncio.create_task(game_loop())
logger.info("Hybrid economy loop started.")
yield yield
logger.info("Server shutting down...") # Shutdown
task.cancel() task.cancel()
database.save_game_state(game_state) database.save_game_state(game_state)
logger.info("Final game state saved.")
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/") @app.get("/")
async def root(): async def root():
"""Serve index.html"""
return FileResponse("static/index.html") return FileResponse("static/index.html")
@app.websocket("/ws/{nickname}") @app.websocket("/ws/{nickname}")
async def websocket_endpoint(websocket: WebSocket, nickname: str): async def websocket_endpoint(websocket: WebSocket, nickname: str):
"""WebSocket endpoint for real-time game communication"""
await ws_manager.connect(websocket, nickname) await ws_manager.connect(websocket, nickname)
try: try:
# Send initial game state
player_id = await ws_manager.get_player_id(websocket) player_id = await ws_manager.get_player_id(websocket)
if player_id: player = game_state.get_or_create_player(nickname, player_id)
player = game_state.get_or_create_player(nickname, player_id)
await websocket.send_json({ await websocket.send_json({
"type": "init", "type": "init",
"player": player.to_dict(), "player": player.to_dict(),
"game_state": game_state.get_state() "game_state": game_state.get_state()
}) })
while True:
data = await websocket.receive_json() # Listen for messages
await handle_message(websocket, data) while True:
data = await websocket.receive_json()
await handle_message(websocket, data)
except WebSocketDisconnect: except WebSocketDisconnect:
await ws_manager.disconnect(websocket) await ws_manager.disconnect(websocket)
except Exception as e:
player_id = await ws_manager.get_player_id(websocket)
logger.error(f"An error occurred in the websocket endpoint for player {player_id}: {e}", exc_info=True)
async def handle_message(websocket: WebSocket, data: dict): async def handle_message(websocket: WebSocket, data: dict):
"""Handle incoming WebSocket messages.""" """Handle incoming WebSocket messages"""
msg_type = data.get("type") msg_type = data.get("type")
player_id = await ws_manager.get_player_id(websocket) player_id = await ws_manager.get_player_id(websocket)
if not player_id:
logger.warning("Received message from an unknown player, ignoring.")
return
logger.debug(f"Received '{msg_type}' from player {player_id}")
if msg_type == "cursor_move": if msg_type == "cursor_move":
await ws_manager.broadcast({"type": "cursor_move", "player_id": player_id, "x": data["x"], "y": data["y"]}, exclude=websocket) # Broadcast cursor position
await ws_manager.broadcast({
"type": "cursor_move",
"player_id": player_id,
"x": data["x"],
"y": data["y"]
}, exclude=websocket)
elif msg_type == "place_building": elif msg_type == "place_building":
result = game_state.place_building(player_id, data["building_type"], data["x"], data["y"]) # Place building
result = game_state.place_building(
player_id,
data["building_type"],
data["x"],
data["y"]
)
if result["success"]: if result["success"]:
await ws_manager.broadcast({"type": "building_placed", "building": result["building"]}) # Broadcast to all players
# --- CHANGE: Trigger an INSTANT economy update on financial action --- await ws_manager.broadcast({
logger.info(f"Player {player_id} action triggered immediate economy update.") "type": "building_placed",
await trigger_economy_update_and_save() "building": result["building"]
})
else: else:
await websocket.send_json({"type": "error", "message": result["error"]}) # Send error to player
await websocket.send_json({
"type": "error",
"message": result["error"]
})
elif msg_type == "remove_building": elif msg_type == "remove_building":
# Remove building
result = game_state.remove_building(player_id, data["x"], data["y"]) result = game_state.remove_building(player_id, data["x"], data["y"])
if result["success"]: if result["success"]:
await ws_manager.broadcast({"type": "building_removed", "x": data["x"], "y": data["y"]}) await ws_manager.broadcast({
# --- CHANGE: Trigger an INSTANT economy update on financial action --- "type": "building_removed",
logger.info(f"Player {player_id} action triggered immediate economy update.") "x": data["x"],
await trigger_economy_update_and_save() "y": data["y"]
else: })
await websocket.send_json({"type": "error", "message": result["error"]})
elif msg_type == "edit_building": elif msg_type == "edit_building":
result = game_state.edit_building_name(player_id, data["x"], data["y"], data["name"]) # Edit building name
result = game_state.edit_building_name(
player_id,
data["x"],
data["y"],
data["name"]
)
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({
database.save_game_state(game_state) "type": "building_updated",
"x": data["x"],
"y": data["y"],
"name": data["name"]
})
elif msg_type == "chat": elif msg_type == "chat":
# Broadcast chat message
nickname = await ws_manager.get_nickname(websocket) nickname = await ws_manager.get_nickname(websocket)
await ws_manager.broadcast({"type": "chat", "nickname": nickname, "message": data["message"], "timestamp": data["timestamp"]}) await ws_manager.broadcast({
"type": "chat",
"nickname": nickname,
"message": data["message"],
"timestamp": data["timestamp"]
})

View File

@ -1,118 +1,90 @@
from fastapi import WebSocket from fastapi import WebSocket
from typing import Dict, List from typing import Dict, Set
import uuid import uuid
import time
from server.game_state import GameState
from server.logger import logger
class WebSocketManager: class WebSocketManager:
"""Manages WebSocket connections for multiplayer""" """Manages WebSocket connections for multiplayer"""
def __init__(self, game_state: GameState): def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {} self.active_connections: Dict[str, WebSocket] = {}
self.player_nicknames: Dict[str, str] = {} self.player_nicknames: Dict[str, str] = {}
self.nickname_to_id: Dict[str, str] = {} self.nickname_to_id: Dict[str, str] = {}
self.game_state = game_state
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"""
logger.debug(f"Connection attempt from nickname '{nickname}'.")
await websocket.accept() await websocket.accept()
# Generate or reuse player ID
if nickname in self.nickname_to_id: if nickname in self.nickname_to_id:
player_id = self.nickname_to_id[nickname] player_id = self.nickname_to_id[nickname]
else: else:
player_id = str(uuid.uuid4()) player_id = str(uuid.uuid4())
self.nickname_to_id[nickname] = player_id self.nickname_to_id[nickname] = player_id
logger.info(f"New player '{nickname}' assigned ID {player_id}.")
player = self.game_state.get_or_create_player(nickname, player_id) self.active_connections[player_id] = websocket
self.player_nicknames[player_id] = nickname
if not self.active_connections.get(player_id): # Broadcast player joined
player.is_online = True await self.broadcast({
self.active_connections[player_id] = [] "type": "player_joined",
logger.info(f"Player '{nickname}' ({player_id}) is now online.") "player_id": player_id,
"nickname": nickname
})
async def disconnect(self, websocket: WebSocket):
"""Disconnect a player"""
player_id = None
for pid, ws in self.active_connections.items():
if ws == websocket:
player_id = pid
break
if player_id:
del self.active_connections[player_id]
nickname = self.player_nicknames.pop(player_id, None)
# Broadcast player left
await self.broadcast({ await self.broadcast({
"type": "player_joined", "type": "player_left",
"player_id": player_id, "player_id": player_id,
"nickname": nickname "nickname": nickname
}) })
self.active_connections[player_id].append(websocket)
self.player_nicknames[player_id] = nickname
logger.debug(f"Added new websocket for '{nickname}'. Total connections for user: {len(self.active_connections[player_id])}.")
async def disconnect(self, websocket: WebSocket):
"""Disconnect a player's specific websocket instance."""
player_id = await self.get_player_id(websocket)
if player_id and player_id in self.active_connections:
self.active_connections[player_id].remove(websocket)
logger.debug(f"Removed websocket for player {player_id}. Remaining connections: {len(self.active_connections[player_id])}.")
if not self.active_connections[player_id]:
del self.active_connections[player_id]
nickname = self.player_nicknames.pop(player_id, None)
# --- REVERTED CHANGE: The following lines that set a player
# --- to 'offline' have been removed to restore original economy.
# player = self.game_state.players.get(player_id)
# if player:
# player.is_online = False
# player.last_online = time.time()
logger.info(f"Player '{nickname}' ({player_id}) last connection closed.")
await self.broadcast({
"type": "player_left",
"player_id": player_id,
"nickname": nickname
})
async def send_to_player(self, player_id: str, message: dict):
"""Send a message to all active connections for a specific player."""
connections = self.active_connections.get(player_id, [])
if not connections: return
logger.debug(f"Sending '{message['type']}' to player {player_id} ({len(connections)} connections).")
disconnected_sockets = []
for websocket in connections:
try:
await websocket.send_json(message)
except Exception:
disconnected_sockets.append(websocket)
for ws in disconnected_sockets:
logger.warning(f"Found dead socket for player {player_id} during send. Cleaning up.")
if ws in self.active_connections.get(player_id, []):
self.active_connections[player_id].remove(ws)
async def broadcast(self, message: dict, exclude: WebSocket = None): async def broadcast(self, message: dict, exclude: WebSocket = None):
"""Broadcast message to all connected players and all their connections.""" """Broadcast message to all connected players"""
msg_type = message.get("type", "unknown") disconnected = []
logger.debug(f"Broadcasting '{msg_type}' to all active connections.")
all_sockets = [] for player_id, websocket in self.active_connections.items():
for ws_list in self.active_connections.values():
all_sockets.extend(ws_list)
for websocket in all_sockets:
if websocket == exclude: if websocket == exclude:
continue continue
try: try:
await websocket.send_json(message) await websocket.send_json(message)
except Exception: except Exception:
pass disconnected.append(player_id)
# Clean up disconnected websockets
for player_id in disconnected:
if player_id in self.active_connections:
del self.active_connections[player_id]
if player_id in self.player_nicknames:
del self.player_nicknames[player_id]
async def broadcast_game_state(self, state: dict):
"""Broadcast full game state"""
await self.broadcast({
"type": "game_state_update",
"state": state
})
async def get_player_id(self, websocket: WebSocket) -> str: async def get_player_id(self, websocket: WebSocket) -> str:
"""Get player ID from a specific websocket instance.""" """Get player ID from websocket"""
for player_id, ws_list in self.active_connections.items(): for player_id, ws in self.active_connections.items():
if websocket in ws_list: if ws == websocket:
return player_id return player_id
return None return None
async def get_nickname(self, websocket: WebSocket) -> str: async def get_nickname(self, websocket: WebSocket) -> str:
"""Get nickname from websocket.""" """Get nickname from websocket"""
player_id = await self.get_player_id(websocket) player_id = await self.get_player_id(websocket)
return self.player_nicknames.get(player_id, "Unknown") return self.player_nicknames.get(player_id, "Unknown")

View File

@ -63,7 +63,7 @@ stats-display {
/* Building Toolbox */ /* Building Toolbox */
building-toolbox { building-toolbox {
position: absolute; position: absolute;
right: 10px; left: 10px;
top: 120px; top: 120px;
background: var(--bg-medium); background: var(--bg-medium);
border: 2px solid var(--border-color); border: 2px solid var(--border-color);

View File

@ -15,33 +15,41 @@ export class App {
players: {}, players: {},
buildings: {} buildings: {}
}; };
this.selectedBuildingType = null; this.selectedBuildingType = null;
this.isPlacingBuilding = false; this.isPlacingBuilding = false;
} }
init() { init() {
console.log('Initializing City Builder...'); console.log('Initializing City Builder...');
// Initialize UI Manager // Initialize UI Manager
this.uiManager = new UIManager(this); this.uiManager = new UIManager(this);
this.uiManager.init(); this.uiManager.init();
// Show login screen // Show login screen
this.uiManager.showLoginScreen(); this.uiManager.showLoginScreen();
} }
async startGame(nickname) { async startGame(nickname) {
console.log(`Starting game for ${nickname}...`); console.log(`Starting game for ${nickname}...`);
// Hide login, show game UI // Hide login, show game UI
this.uiManager.hideLoginScreen(); this.uiManager.hideLoginScreen();
this.uiManager.showGameUI(); this.uiManager.showGameUI();
// Initialize renderer // Initialize renderer
this.renderer = new GameRenderer(); this.renderer = new GameRenderer();
this.renderer.init(); this.renderer.init();
// Initialize input handler // Initialize input handler
this.inputHandler = new InputHandler(this); this.inputHandler = new InputHandler(this);
this.inputHandler.init(); this.inputHandler.init();
// Connect to WebSocket // Connect to WebSocket
this.wsClient = new WebSocketClient(this); this.wsClient = new WebSocketClient(this);
await this.wsClient.connect(nickname); await this.wsClient.connect(nickname);
// Start render loop // Start render loop
this.renderer.startRenderLoop(); this.renderer.startRenderLoop();
} }
@ -54,22 +62,21 @@ export class App {
// Update UI // Update UI
this.uiManager.updateStats(this.player); this.uiManager.updateStats(this.player);
this.uiManager.updateBuildingToolbox(this.player); this.uiManager.updateBuildingToolbox(this.player);
// Render initial state // Render initial state
this.renderer.updateGameState(gameState); this.renderer.updateGameState(gameState);
} }
onPlayerStatsUpdate(playerData) { onGameStateUpdate(state) {
// Update our own player object and UI if the update is for us this.gameState = state;
if (this.player && this.player.player_id === playerData.player_id) { this.renderer.updateGameState(state);
this.player = playerData;
// 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.updateStats(this.player);
this.uiManager.updateBuildingToolbox(this.player); this.uiManager.updateBuildingToolbox(this.player);
} }
// Update the player in the global game state as well
if (this.gameState.players[playerData.player_id]) {
this.gameState.players[playerData.player_id] = playerData;
}
} }
onCursorMove(playerId, x, y) { onCursorMove(playerId, x, y) {
@ -79,22 +86,16 @@ export class App {
onBuildingPlaced(building) { onBuildingPlaced(building) {
console.log('Building placed:', building); console.log('Building placed:', building);
this.renderer.addBuilding(building); this.renderer.addBuilding(building);
// Also add to local game state for context menu checks etc.
this.gameState.buildings[`${building.x},${building.y}`] = building;
} }
onBuildingRemoved(x, y) { onBuildingRemoved(x, y) {
console.log('Building removed at:', x, y); console.log('Building removed at:', x, y);
this.renderer.removeBuilding(x, y); this.renderer.removeBuilding(x, y);
delete this.gameState.buildings[`${x},${y}`];
} }
onBuildingUpdated(x, y, name) { onBuildingUpdated(x, y, name) {
console.log('Building updated:', x, y, name); console.log('Building updated:', x, y, name);
this.renderer.updateBuildingName(x, y, name); this.renderer.updateBuildingName(x, y, name);
if (this.gameState.buildings[`${x},${y}`]) {
this.gameState.buildings[`${x},${y}`].name = name;
}
} }
onPlayerJoined(playerId, nickname) { onPlayerJoined(playerId, nickname) {
@ -126,6 +127,7 @@ export class App {
placeBuilding(x, y) { placeBuilding(x, y) {
if (!this.selectedBuildingType) return; if (!this.selectedBuildingType) return;
console.log('Placing building:', this.selectedBuildingType, 'at', x, y); console.log('Placing building:', this.selectedBuildingType, 'at', x, y);
this.wsClient.placeBuilding(this.selectedBuildingType, x, y); this.wsClient.placeBuilding(this.selectedBuildingType, x, y);
} }

View File

@ -5,18 +5,14 @@ export class GameRenderer {
this.renderer = null; this.renderer = null;
this.canvas = null; this.canvas = null;
this.tiles = new Map(); this.tiles = new Map(); // Map of tile meshes
// Map of tile meshes this.buildings = new Map(); // Map of building meshes
this.buildings = new Map(); this.cursors = new Map(); // Map of player cursors
// Map of building meshes this.labels = new Map(); // Map of building labels
this.cursors = new Map();
// Map of player cursors
this.labels = new Map();
// Map of building labels
this.hoveredTile = null; this.hoveredTile = null;
this.cameraPos = { x: 0, y: 50, z: 50 }; this.cameraPos = { x: 0, y: 50, z: 50 };
this.cameraZoom = 1; // Re-introduced for proper orthographic zoom this.cameraZoom = 1;
this.TILE_SIZE = 2; this.TILE_SIZE = 2;
this.VIEW_DISTANCE = 50; this.VIEW_DISTANCE = 50;
@ -24,10 +20,10 @@ export class GameRenderer {
init() { init() {
this.canvas = document.getElementById('gameCanvas'); this.canvas = document.getElementById('gameCanvas');
// Create scene // Create scene
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x87CEEB); this.scene.background = new THREE.Color(0x87CEEB); // Sky blue
// Sky blue
// Create camera // Create camera
this.camera = new THREE.OrthographicCamera( this.camera = new THREE.OrthographicCamera(
@ -52,8 +48,10 @@ export class GameRenderer {
directionalLight.position.set(10, 20, 10); directionalLight.position.set(10, 20, 10);
directionalLight.castShadow = true; directionalLight.castShadow = true;
this.scene.add(directionalLight); this.scene.add(directionalLight);
// Create ground // Create ground
this.createGround(); this.createGround();
// Handle window resize // Handle window resize
window.addEventListener('resize', () => this.onResize()); window.addEventListener('resize', () => this.onResize());
} }
@ -83,13 +81,13 @@ 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 // Get building height and color based on type
let height = 1; let height = 1;
let color = 0x808080; let color = 0x808080;
if (type.includes('house')) { if (type.includes('house')) {
height = type === 'small_house' ? height = type === 'small_house' ? 2 : type === 'medium_house' ? 3 : 4;
2 : type === 'medium_house' ? 3 : 4;
color = 0xD2691E; color = 0xD2691E;
} else if (type.includes('shop') || type === 'supermarket' || type === 'mall') { } else if (type.includes('shop') || type === 'supermarket' || type === 'mall') {
height = 3; height = 3;
@ -156,6 +154,7 @@ export class GameRenderer {
addBuilding(buildingData) { addBuilding(buildingData) {
const key = `${buildingData.x},${buildingData.y}`; const key = `${buildingData.x},${buildingData.y}`;
// Remove existing building at this position // Remove existing building at this position
if (this.buildings.has(key)) { if (this.buildings.has(key)) {
this.scene.remove(this.buildings.get(key)); this.scene.remove(this.buildings.get(key));
@ -242,21 +241,17 @@ export class GameRenderer {
} }
zoomCamera(delta) { zoomCamera(delta) {
// Adjust the zoom property. A positive delta (scroll up) increases zoom. this.cameraZoom = Math.max(0.5, Math.min(2, this.cameraZoom + delta));
this.cameraZoom += delta; this.updateCameraPosition();
// Clamp the zoom level to a reasonable range
this.cameraZoom = Math.max(0.5, Math.min(2.5, this.cameraZoom));
// Apply the zoom to the camera and update its projection matrix
this.camera.zoom = this.cameraZoom;
this.camera.updateProjectionMatrix();
} }
updateCameraPosition() { updateCameraPosition() {
this.camera.position.set( this.camera.position.set(
this.cameraPos.x, this.cameraPos.y, this.cameraPos.z this.cameraPos.x,
this.cameraPos.y * this.cameraZoom,
this.cameraPos.z * this.cameraZoom
); );
this.camera.lookAt(this.cameraPos.x, 0, this.cameraPos.z - 50); this.camera.lookAt(this.cameraPos.x, 0, 0);
} }
startRenderLoop() { startRenderLoop() {

View File

@ -9,19 +9,21 @@ export class InputHandler {
this.currentTileX = null; this.currentTileX = null;
this.currentTileY = null; this.currentTileY = null;
this.cursorUpdateThrottle = 100; // ms this.cursorUpdateThrottle = 100; // ms
this.lastCursorUpdate = 0; this.lastCursorUpdate = 0;
this.keyPanSpeed = 3; // Controls camera movement speed with arrow keys
} }
init() { init() {
this.canvas = document.getElementById('gameCanvas'); this.canvas = document.getElementById('gameCanvas');
// Mouse events // Mouse events
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e)); this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
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()); this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard events // Keyboard events
document.addEventListener('keydown', (e) => this.onKeyDown(e)); document.addEventListener('keydown', (e) => this.onKeyDown(e));
} }
@ -34,9 +36,12 @@ export class InputHandler {
this.canvas.style.cursor = 'grabbing'; this.canvas.style.cursor = 'grabbing';
} else if (event.button === 0) { // Left mouse button } else if (event.button === 0) { // Left mouse button
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY); const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
if (this.app.isPlacingBuilding && this.app.selectedBuildingType) { if (this.app.isPlacingBuilding && this.app.selectedBuildingType) {
// Place building // Place building
this.app.placeBuilding(tile.x, tile.y); this.app.placeBuilding(tile.x, tile.y);
this.app.isPlacingBuilding = false;
this.app.selectedBuildingType = null;
} }
} }
} }
@ -44,17 +49,13 @@ export class InputHandler {
onMouseUp(event) { onMouseUp(event) {
if (event.button === 2) { // Right mouse button if (event.button === 2) { // Right mouse button
this.isRightMouseDown = false; this.isRightMouseDown = false;
// A right-click should cancel building placement mode
this.app.isPlacingBuilding = false;
this.app.selectedBuildingType = null;
this.canvas.style.cursor = 'default'; this.canvas.style.cursor = 'default';
// Check if click (not drag) // Check if click (not drag)
const dragThreshold = 5; const dragThreshold = 5;
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
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY); const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
@ -75,12 +76,14 @@ export class InputHandler {
onMouseMove(event) { onMouseMove(event) {
// Update tile position // Update tile position
const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY); const tile = this.app.renderer.screenToWorld(event.clientX, event.clientY);
if (tile.x !== this.currentTileX || tile.y !== this.currentTileY) { if (tile.x !== this.currentTileX || tile.y !== this.currentTileY) {
this.currentTileX = tile.x; this.currentTileX = tile.x;
this.currentTileY = tile.y; this.currentTileY = tile.y;
// Highlight tile // Highlight tile
this.app.renderer.highlightTile(tile.x, tile.y); this.app.renderer.highlightTile(tile.x, tile.y);
// 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) {
@ -108,36 +111,11 @@ export class InputHandler {
} }
onKeyDown(event) { onKeyDown(event) {
// ESC to cancel building placement
if (event.key === 'Escape') { if (event.key === 'Escape') {
this.app.isPlacingBuilding = false; this.app.isPlacingBuilding = false;
this.app.selectedBuildingType = null; this.app.selectedBuildingType = null;
this.app.uiManager.hideContextMenu(); this.app.uiManager.hideContextMenu();
} }
let moved = false;
const s = this.keyPanSpeed; // shorthand for speed
switch (event.key) {
case 'ArrowUp':
this.app.renderer.moveCamera(s, -s); // Move diagonally to pan straight up
moved = true;
break;
case 'ArrowDown':
this.app.renderer.moveCamera(-s, s); // Move diagonally to pan straight down
moved = true;
break;
case 'ArrowLeft':
this.app.renderer.moveCamera(-s, -s); // Move diagonally to pan straight left
moved = true;
break;
case 'ArrowRight':
this.app.renderer.moveCamera(s, s); // Move diagonally to pan straight right
moved = true;
break;
}
if (moved) {
event.preventDefault(); // Prevents the browser from scrolling
}
} }
} }

View File

@ -12,6 +12,7 @@ export class WebSocketClient {
try { try {
this.ws = new WebSocket(wsUrl); this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
@ -49,9 +50,10 @@ export class WebSocketClient {
this.app.onPlayerInit(data.player, data.game_state); this.app.onPlayerInit(data.player, data.game_state);
break; break;
case 'player_stats_update': case 'game_state_update':
this.app.onPlayerStatsUpdate(data.player); this.app.onGameStateUpdate(data.state);
break; break;
case 'cursor_move': case 'cursor_move':
this.app.onCursorMove(data.player_id, data.x, data.y); this.app.onCursorMove(data.player_id, data.x, data.y);
break; break;
@ -59,9 +61,11 @@ export class WebSocketClient {
case 'building_placed': case 'building_placed':
this.app.onBuildingPlaced(data.building); this.app.onBuildingPlaced(data.building);
break; break;
case 'building_removed': case 'building_removed':
this.app.onBuildingRemoved(data.x, data.y); this.app.onBuildingRemoved(data.x, data.y);
break; break;
case 'building_updated': case 'building_updated':
this.app.onBuildingUpdated(data.x, data.y, data.name); this.app.onBuildingUpdated(data.x, data.y, data.name);
break; break;

View File

@ -6,7 +6,6 @@ class BuildingToolbox extends HTMLElement {
constructor() { constructor() {
super(); super();
this.app = null; this.app = null;
this.buildingItems = []; // A cache for the building DOM elements
this.buildings = [ this.buildings = [
{ type: 'small_house', name: 'Small House', cost: 5000, income: -50, pop: 10 }, { 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: 'medium_house', name: 'Medium House', cost: 12000, income: -120, pop: 25 },
@ -25,30 +24,18 @@ class BuildingToolbox extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
// 1. Initial full render happens only once this.render();
this.innerHTML = this.renderHTML();
// 2. Cache the DOM elements for efficient future updates
this.buildingItems = this.querySelectorAll('.building-item');
// 3. Add click handlers only once
this.addClickHandlers();
} }
attributeChangedCallback() { attributeChangedCallback() {
// When player stats change, run the lightweight update function this.render();
// instead of a full re-render.
if (this.buildingItems.length > 0) {
this.updateItemStates();
}
} }
renderHTML() { render() {
// This function generates the initial HTML string.
const money = parseInt(this.getAttribute('player-money') || '0'); const money = parseInt(this.getAttribute('player-money') || '0');
const population = parseInt(this.getAttribute('player-population') || '0'); const population = parseInt(this.getAttribute('player-population') || '0');
return ` this.innerHTML = `
<div style="font-weight: bold; margin-bottom: 10px; font-size: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 5px;"> <div style="font-weight: bold; margin-bottom: 10px; font-size: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 5px;">
Buildings Buildings
</div> </div>
@ -57,6 +44,7 @@ class BuildingToolbox extends HTMLElement {
const canAfford = money >= building.cost; const canAfford = money >= building.cost;
const meetsReq = !building.req || population >= building.req; const meetsReq = !building.req || population >= building.req;
const enabled = canAfford && meetsReq; const enabled = canAfford && meetsReq;
return ` return `
<div <div
class="building-item" class="building-item"
@ -67,7 +55,8 @@ class BuildingToolbox extends HTMLElement {
background: var(--bg-light); background: var(--bg-light);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
cursor: ${enabled ? 'pointer' : 'not-allowed'}; cursor: ${enabled ? 'pointer' : 'not-allowed'};
opacity: ${enabled ? '1' : '0.5'};" opacity: ${enabled ? '1' : '0.5'};
"
> >
<div style="font-weight: bold; margin-bottom: 4px;"> <div style="font-weight: bold; margin-bottom: 4px;">
${building.name} ${building.name}
@ -85,15 +74,12 @@ class BuildingToolbox extends HTMLElement {
}).join('')} }).join('')}
</div> </div>
`; `;
}
// Add click handlers
addClickHandlers() { this.querySelectorAll('.building-item').forEach(item => {
this.buildingItems.forEach(item => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
const type = item.dataset.type; const type = item.dataset.type;
const building = this.buildings.find(b => b.type === type); const building = this.buildings.find(b => b.type === type);
const money = parseInt(this.getAttribute('player-money') || '0');
const population = parseInt(this.getAttribute('player-population') || '0');
if (money >= building.cost && (!building.req || population >= building.req)) { if (money >= building.cost && (!building.req || population >= building.req)) {
if (this.app) { if (this.app) {
@ -103,26 +89,6 @@ class BuildingToolbox extends HTMLElement {
}); });
}); });
} }
updateItemStates() {
// This lightweight function only updates styles, preserving the DOM and scroll position.
const money = parseInt(this.getAttribute('player-money') || '0');
const population = parseInt(this.getAttribute('player-population') || '0');
this.buildingItems.forEach(item => {
const type = item.dataset.type;
const building = this.buildings.find(b => b.type === type);
if (!building) return;
const canAfford = money >= building.cost;
const meetsReq = !building.req || population >= building.req;
const enabled = canAfford && meetsReq;
// Directly update only the styles that change
item.style.opacity = enabled ? '1' : '0.5';
item.style.cursor = enabled ? 'pointer' : 'not-allowed';
});
}
} }
customElements.define('building-toolbox', BuildingToolbox); customElements.define('building-toolbox', BuildingToolbox);