Compare commits
5 Commits
416cc4511b
...
a547b6ede1
| Author | SHA1 | Date | |
|---|---|---|---|
| a547b6ede1 | |||
| 084fce0a9b | |||
| 83f28da5c1 | |||
| 3c783056cf | |||
| 6a2f94337e |
@ -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": []}
|
||||||
|
|||||||
@ -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,54 +11,44 @@ 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)
|
||||||
player.population = max(0, total_population)
|
|
||||||
|
|
||||||
# Prevent negative money (but allow debt for realism)
|
total_population = player_total_population.get(player_id, 0)
|
||||||
if player.money < -100000:
|
player.population = max(0, total_population)
|
||||||
player.money = -100000
|
|
||||||
|
if 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"""
|
||||||
|
|||||||
@ -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
35
server/logger.py
Normal 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()
|
||||||
184
server/main.py
184
server/main.py
@ -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
|
||||||
|
|
||||||
|
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:
|
||||||
try:
|
# Check if 10 seconds have passed since the last tick (from any source)
|
||||||
await asyncio.sleep(10)
|
if time.time() - last_economy_tick_time > TICK_INTERVAL:
|
||||||
|
logger.info("Triggering timed economy update for idle players.")
|
||||||
|
await trigger_economy_update_and_save()
|
||||||
|
|
||||||
# Economy tick
|
# Check frequently for responsiveness
|
||||||
economy_engine.tick()
|
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."""
|
||||||
# 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)
|
||||||
player = game_state.get_or_create_player(nickname, player_id)
|
if 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:
|
||||||
# Listen for messages
|
data = await websocket.receive_json()
|
||||||
while True:
|
await handle_message(websocket, data)
|
||||||
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":
|
||||||
# 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"]
|
|
||||||
})
|
|
||||||
|
|||||||
@ -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):
|
||||||
await self.broadcast({
|
player.is_online = True
|
||||||
"type": "player_joined",
|
self.active_connections[player_id] = []
|
||||||
"player_id": player_id,
|
logger.info(f"Player '{nickname}' ({player_id}) is now online.")
|
||||||
"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_left",
|
"type": "player_joined",
|
||||||
"player_id": player_id,
|
"player_id": player_id,
|
||||||
"nickname": nickname
|
"nickname": nickname
|
||||||
})
|
})
|
||||||
|
|
||||||
async def broadcast(self, message: dict, exclude: WebSocket = None):
|
self.active_connections[player_id].append(websocket)
|
||||||
"""Broadcast message to all connected players"""
|
self.player_nicknames[player_id] = nickname
|
||||||
disconnected = []
|
logger.debug(f"Added new websocket for '{nickname}'. Total connections for user: {len(self.active_connections[player_id])}.")
|
||||||
|
|
||||||
for player_id, websocket in self.active_connections.items():
|
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):
|
||||||
|
"""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")
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user