Compare commits

...

5 Commits

Author SHA1 Message Date
a547b6ede1 Changed input mode. 2025-10-05 00:18:06 +02:00
084fce0a9b Created logger. 2025-10-05 00:08:47 +02:00
83f28da5c1 Fixed arrow buttons. 2025-10-05 00:08:32 +02:00
3c783056cf Concurrency. 2025-10-04 23:46:44 +02:00
6a2f94337e Financial updates and performance updates. 2025-10-04 23:26:27 +02:00
12 changed files with 429 additions and 339 deletions

View File

@ -2,6 +2,8 @@ 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"""
@ -18,8 +20,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,
@ -30,8 +32,6 @@ 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,78 +44,65 @@ 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:
# Save players using INSERT OR REPLACE to handle existing nicknames player_data = [
for player in game_state.players.values(): (p.player_id, p.nickname, p.money, p.population, p.color, p.last_online)
conn.execute(''' for p in game_state.players.values()
]
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,
player.nickname,
player.money,
player.population,
player.color,
player.last_online
))
# Save buildings cursor = conn.execute('SELECT x, y FROM buildings')
conn.execute('DELETE FROM buildings') db_coords = {(row['x'], row['y']) for row in cursor}
gs_coords = set(game_state.buildings.keys())
for building in game_state.buildings.values(): coords_to_delete = db_coords - gs_coords
conn.execute(''' if coords_to_delete:
INSERT INTO buildings (x, y, type, owner_id, name, placed_at) conn.executemany('DELETE FROM buildings WHERE x = ? AND y = ?', list(coords_to_delete))
building_data = [
(b.x, b.y, b.building_type.value, b.owner_id, b.name, b.placed_at)
for b in game_state.buildings.values()
]
if building_data:
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:
# Load players players, buildings = [], []
players = []
cursor = conn.execute('SELECT * FROM players') cursor = conn.execute('SELECT * FROM players')
for row in cursor: for row in cursor:
players.append({ players.append(dict(row))
"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({ buildings.append(dict(row))
"x": row["x"],
"y": row["y"],
"type": row["type"],
"owner_id": row["owner_id"],
"name": row["name"],
"placed_at": row["placed_at"]
})
return { duration = time.perf_counter() - start_time
"players": players, logger.info(f"Loaded {len(players)} players and {len(buildings)} buildings in {duration:.4f} seconds.")
"buildings": buildings return {"players": players, "buildings": buildings}
}
except Exception as e: except Exception as e:
print(f"Error loading game state: {e}") logger.error(f"Error loading game state: {e}", exc_info=True)
return {"players": [], "buildings": []} return {"players": [], "buildings": []}

View File

@ -1,6 +1,8 @@
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"""
@ -9,55 +11,45 @@ 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""" """
current_time = time.time() Process one economy tick for all players using an efficient aggregate calculation.
"""
logger.debug("Processing economy tick...")
start_time = time.perf_counter()
for player in self.game_state.players.values(): player_money_deltas = defaultdict(int)
# Calculate power factor (10% if offline, 100% if online) player_total_population = defaultdict(int)
time_diff = current_time - player.last_online
if player.is_online:
power_factor = 1.0
else:
power_factor = 0.1
# Process player economy # 1. Single pass over all buildings to aggregate their effects
self._process_player_economy(player, power_factor) for building in self.game_state.buildings.values():
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] config = BUILDING_CONFIGS[building.building_type]
owner_id = building.owner_id
# 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) # 5% per road in zone connectivity_bonus = 1.0 + (zone_size * 0.05)
base_income = int(base_income * connectivity_bonus) base_income = int(base_income * connectivity_bonus)
# Add to totals player_money_deltas[owner_id] += base_income
total_income += base_income player_total_population[owner_id] += config.population
total_population += config.population
# Apply power factor # 2. Apply the aggregated deltas to each player
total_income = int(total_income * power_factor) for player_id, player in self.game_state.players.items():
power_factor = 1.0 if player.is_online else 0.1
# Update player stats money_delta = player_money_deltas.get(player_id, 0)
player.money += total_income player.money += int(money_delta * power_factor)
total_population = player_total_population.get(player_id, 0)
player.population = max(0, total_population) player.population = max(0, total_population)
# Prevent negative money (but allow debt for realism)
if player.money < -100000: if player.money < -100000:
player.money = -100000 player.money = -100000
duration = time.perf_counter() - start_time
logger.debug(f"Economy tick processed in {duration:.4f} seconds for {len(self.game_state.players)} players.")
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,6 +1,7 @@
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"""
@ -17,6 +18,7 @@ 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(
@ -25,50 +27,48 @@ 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"""
# Check if tile is occupied logger.debug(f"Attempting to place '{building_type}' at ({x},{y}) for player {player_id}.")
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"}
# Place building building = Building(building_type=b_type, x=x, y=y, owner_id=player_id, placed_at=time.time())
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)
# Update road network if it's a road logger.info(f"Player {player_id} placed '{building_type}' at ({x},{y}). New balance: ${player.money}.")
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,18 +77,20 @@ 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()
@ -98,21 +100,19 @@ 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 if (building.owner_id == player_id and building.building_type == BuildingType.POWER_PLANT):
building.building_type == BuildingType.POWER_PLANT):
return True return True
return False return False
@ -122,6 +122,9 @@ 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 = []
@ -129,19 +132,13 @@ 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: if pos in visited: continue
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)
@ -150,13 +147,14 @@ class GameState:
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)
@ -178,13 +176,11 @@ 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 player.is_online = False # All players start as offline
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"]),
@ -200,3 +196,4 @@ 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.")

35
server/logger.py Normal file
View File

@ -0,0 +1,35 @@
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,157 +4,153 @@ 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()
# Background task for economy ticks and persistence # --- HYBRID MODEL RE-IMPLEMENTED ---
async def game_loop(): last_economy_tick_time = time.time()
"""Main game loop: economy ticks every 10 seconds, DB save every 10 seconds""" TICK_INTERVAL = 10 # seconds
while True:
try: async def trigger_economy_update_and_save():
await asyncio.sleep(10) """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 tick
economy_engine.tick() economy_engine.tick()
# Save to database 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) database.save_game_state(game_state)
# Broadcast state to all players # Reset the global tick timer after any update
await ws_manager.broadcast_game_state(game_state.get_state()) last_economy_tick_time = time.time()
except Exception as e:
print(f"Error in game loop: {e}") duration = time.perf_counter() - start_time
# Continue running even if there's an error 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:
# Check if 10 seconds have passed since the last tick (from any source)
if time.time() - last_economy_tick_time > TICK_INTERVAL:
logger.info("Triggering timed economy update for idle players.")
await trigger_economy_update_and_save()
# Check frequently for responsiveness
await asyncio.sleep(1)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup and shutdown events""" """Startup and shutdown events."""
# Startup logger.info("Server starting up...")
database.init_db() database.init_db()
game_state.load_state(database.load_game_state()) game_state.load_state(database.load_game_state())
# Start game loop # Start the hybrid economy loop
task = asyncio.create_task(game_loop()) task = asyncio.create_task(economy_loop())
logger.info("Hybrid economy loop started.")
yield yield
# Shutdown logger.info("Server shutting down...")
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()
}) })
# Listen for messages
while True: while True:
data = await websocket.receive_json() data = await websocket.receive_json()
await handle_message(websocket, data) 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":
# Broadcast cursor position await ws_manager.broadcast({"type": "cursor_move", "player_id": player_id, "x": data["x"], "y": data["y"]}, exclude=websocket)
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":
# Place building result = game_state.place_building(player_id, data["building_type"], data["x"], data["y"])
result = game_state.place_building(
player_id,
data["building_type"],
data["x"],
data["y"]
)
if result["success"]: if result["success"]:
# Broadcast to all players await ws_manager.broadcast({"type": "building_placed", "building": result["building"]})
await ws_manager.broadcast({ # --- CHANGE: Trigger an INSTANT economy update on financial action ---
"type": "building_placed", logger.info(f"Player {player_id} action triggered immediate economy update.")
"building": result["building"] await trigger_economy_update_and_save()
})
else: else:
# Send error to player await websocket.send_json({"type": "error", "message": result["error"]})
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({ await ws_manager.broadcast({"type": "building_removed", "x": data["x"], "y": data["y"]})
"type": "building_removed", # --- CHANGE: Trigger an INSTANT economy update on financial action ---
"x": data["x"], logger.info(f"Player {player_id} action triggered immediate economy update.")
"y": data["y"] await trigger_economy_update_and_save()
}) else:
await websocket.send_json({"type": "error", "message": result["error"]})
elif msg_type == "edit_building": elif msg_type == "edit_building":
# Edit building name result = game_state.edit_building_name(player_id, data["x"], data["y"], data["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({ await ws_manager.broadcast({"type": "building_updated", "x": data["x"], "y": data["y"], "name": data["name"]})
"type": "building_updated", database.save_game_state(game_state)
"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({ await ws_manager.broadcast({"type": "chat", "nickname": nickname, "message": data["message"], "timestamp": data["timestamp"]})
"type": "chat",
"nickname": nickname,
"message": data["message"],
"timestamp": data["timestamp"]
})

View File

@ -1,90 +1,118 @@
from fastapi import WebSocket from fastapi import WebSocket
from typing import Dict, Set from typing import Dict, List
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): def __init__(self, game_state: GameState):
self.active_connections: Dict[str, WebSocket] = {} self.active_connections: Dict[str, List[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""" """Connect a new player, allowing multiple connections per nickname."""
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}.")
self.active_connections[player_id] = websocket player = self.game_state.get_or_create_player(nickname, player_id)
self.player_nicknames[player_id] = nickname
# Broadcast player joined if not self.active_connections.get(player_id):
player.is_online = True
self.active_connections[player_id] = []
logger.info(f"Player '{nickname}' ({player_id}) is now online.")
await self.broadcast({ await self.broadcast({
"type": "player_joined", "type": "player_joined",
"player_id": player_id, "player_id": player_id,
"nickname": nickname "nickname": nickname
}) })
async def disconnect(self, websocket: WebSocket): self.active_connections[player_id].append(websocket)
"""Disconnect a player""" self.player_nicknames[player_id] = nickname
player_id = None logger.debug(f"Added new websocket for '{nickname}'. Total connections for user: {len(self.active_connections[player_id])}.")
for pid, ws in self.active_connections.items():
if ws == websocket:
player_id = pid
break
if 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] del self.active_connections[player_id]
nickname = self.player_nicknames.pop(player_id, None) nickname = self.player_nicknames.pop(player_id, None)
# Broadcast player left # --- 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({ await self.broadcast({
"type": "player_left", "type": "player_left",
"player_id": player_id, "player_id": player_id,
"nickname": nickname "nickname": nickname
}) })
async def broadcast(self, message: dict, exclude: WebSocket = None): async def send_to_player(self, player_id: str, message: dict):
"""Broadcast message to all connected players""" """Send a message to all active connections for a specific player."""
disconnected = [] connections = self.active_connections.get(player_id, [])
if not connections: return
for player_id, websocket in self.active_connections.items(): 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):
"""Broadcast message to all connected players and all their connections."""
msg_type = message.get("type", "unknown")
logger.debug(f"Broadcasting '{msg_type}' to all active connections.")
all_sockets = []
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:
disconnected.append(player_id) pass
# 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 websocket""" """Get player ID from a specific websocket instance."""
for player_id, ws in self.active_connections.items(): for player_id, ws_list in self.active_connections.items():
if ws == websocket: if websocket in ws_list:
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;
left: 10px; right: 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,41 +15,33 @@ 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();
} }
@ -62,21 +54,22 @@ 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);
} }
onGameStateUpdate(state) { onPlayerStatsUpdate(playerData) {
this.gameState = state; // Update our own player object and UI if the update is for us
this.renderer.updateGameState(state); if (this.player && this.player.player_id === playerData.player_id) {
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) {
@ -86,16 +79,22 @@ 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) {
@ -127,7 +126,6 @@ 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,14 +5,18 @@ export class GameRenderer {
this.renderer = null; this.renderer = null;
this.canvas = null; this.canvas = null;
this.tiles = new Map(); // Map of tile meshes this.tiles = new Map();
this.buildings = new Map(); // Map of building meshes // Map of tile meshes
this.cursors = new Map(); // Map of player cursors this.buildings = new Map();
this.labels = new Map(); // Map of building labels // Map of building meshes
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; this.cameraZoom = 1; // Re-introduced for proper orthographic zoom
this.TILE_SIZE = 2; this.TILE_SIZE = 2;
this.VIEW_DISTANCE = 50; this.VIEW_DISTANCE = 50;
@ -20,10 +24,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); // Sky blue this.scene.background = new THREE.Color(0x87CEEB);
// Sky blue
// Create camera // Create camera
this.camera = new THREE.OrthographicCamera( this.camera = new THREE.OrthographicCamera(
@ -48,10 +52,8 @@ 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());
} }
@ -81,13 +83,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' ? 2 : type === 'medium_house' ? 3 : 4; height = type === 'small_house' ?
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;
@ -154,7 +156,6 @@ 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));
@ -241,17 +242,21 @@ export class GameRenderer {
} }
zoomCamera(delta) { zoomCamera(delta) {
this.cameraZoom = Math.max(0.5, Math.min(2, this.cameraZoom + delta)); // Adjust the zoom property. A positive delta (scroll up) increases zoom.
this.updateCameraPosition(); this.cameraZoom += delta;
// 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.x, this.cameraPos.y, this.cameraPos.z
this.cameraPos.y * this.cameraZoom,
this.cameraPos.z * this.cameraZoom
); );
this.camera.lookAt(this.cameraPos.x, 0, 0); this.camera.lookAt(this.cameraPos.x, 0, this.cameraPos.z - 50);
} }
startRenderLoop() { startRenderLoop() {

View File

@ -9,21 +9,19 @@ 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));
} }
@ -36,12 +34,9 @@ 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;
} }
} }
} }
@ -49,13 +44,17 @@ 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);
@ -76,14 +75,12 @@ 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) {
@ -111,11 +108,36 @@ 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,7 +12,6 @@ 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;
@ -50,10 +49,9 @@ export class WebSocketClient {
this.app.onPlayerInit(data.player, data.game_state); this.app.onPlayerInit(data.player, data.game_state);
break; break;
case 'game_state_update': case 'player_stats_update':
this.app.onGameStateUpdate(data.state); this.app.onPlayerStatsUpdate(data.player);
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;
@ -61,11 +59,9 @@ 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,6 +6,7 @@ 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 },
@ -24,18 +25,30 @@ class BuildingToolbox extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this.render(); // 1. Initial full render happens only once
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() {
this.render(); // When player stats change, run the lightweight update function
// instead of a full re-render.
if (this.buildingItems.length > 0) {
this.updateItemStates();
}
} }
render() { renderHTML() {
// 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');
this.innerHTML = ` return `
<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>
@ -44,7 +57,6 @@ 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"
@ -55,8 +67,7 @@ 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}
@ -74,12 +85,15 @@ 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) {
@ -89,6 +103,26 @@ 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);