From d519cdfd0e9286afefa0d2c07e2caaab46a7ae16 Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 1 Oct 2025 20:15:47 +0200 Subject: [PATCH] Update. --- .gitignore | 3 + app.py | 251 +++++++++++++++++++++++++++++++++ index.html | 407 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f94a840 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.txt +*.db +__pycache__ diff --git a/app.py b/app.py new file mode 100644 index 0000000..518b854 --- /dev/null +++ b/app.py @@ -0,0 +1,251 @@ +import asyncio +import json +import logging +import sqlite3 +from typing import Dict, Any, Optional, List, Tuple +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from pathlib import Path + +# --- Setup --- +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +app = FastAPI() +DB_FILE = Path("tycoon.db") + +# --- Database Management --- +def init_db(): + """Initializes the database and creates tables if they don't exist.""" + try: + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS players ( + nickname TEXT PRIMARY KEY, + money INTEGER NOT NULL, + population INTEGER NOT NULL + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS buildings ( + key TEXT PRIMARY KEY, + owner_nickname TEXT NOT NULL, + type TEXT NOT NULL, + cost INTEGER NOT NULL, + FOREIGN KEY (owner_nickname) REFERENCES players (nickname) + ) + """) + conn.commit() + conn.close() + logger.info("Database initialized successfully.") + except sqlite3.Error as e: + logger.error(f"Database error on init: {e}") + +def db_execute(query: str, params: tuple = ()): + """Executes a write query (INSERT, UPDATE, DELETE).""" + try: + with sqlite3.connect(DB_FILE) as conn: + cursor = conn.cursor() + cursor.execute(query, params) + conn.commit() + except sqlite3.Error as e: + logger.error(f"DB execute error: {e} with query: {query}") + +def db_fetchone(query: str, params: tuple = ()) -> Optional[Any]: + """Fetches a single row from the database.""" + try: + with sqlite3.connect(DB_FILE) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(query, params) + return cursor.fetchone() + except sqlite3.Error as e: + logger.error(f"DB fetchone error: {e} with query: {query}") + return None + +def db_fetchall(query: str, params: tuple = ()) -> list: + """Fetches all rows from the database.""" + try: + with sqlite3.connect(DB_FILE) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(query, params) + return cursor.fetchall() + except sqlite3.Error as e: + logger.error(f"DB fetchall error: {e} with query: {query}") + return [] + +# --- Connection Manager --- +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, WebSocket] = {} + + async def connect(self, websocket: WebSocket, nickname: str): + await websocket.accept() + self.active_connections[nickname] = websocket + logger.info(f"Player connected: {nickname}") + + def disconnect(self, nickname: str): + if nickname in self.active_connections: + del self.active_connections[nickname] + logger.info(f"Player disconnected: {nickname}") + + async def broadcast(self, message: str): + disconnected_players = [] + for nickname, connection in self.active_connections.items(): + try: + await connection.send_text(message) + except Exception: + disconnected_players.append(nickname) + for nickname in disconnected_players: + self.disconnect(nickname) + +manager = ConnectionManager() + +# --- Game State & Data --- +game_state: Dict[str, Any] = { + "players": {}, + "buildings": {} +} + +building_data = { + 'residential': { 'cost': 100, 'population': 10 }, + 'commercial': { 'cost': 250, 'income': 5 }, + 'industrial': { 'cost': 500, 'income': 20 }, + 'park': { 'cost': 80, 'population_bonus': 5 }, + 'powerplant': { 'cost': 1000, 'income': 50 }, + 'road': { 'cost': 20 } +} + +# --- Game Logic --- +def get_neighbors(key: str) -> List[str]: + """Gets the keys of the four adjacent grid cells.""" + x_str, z_str = key.split('_') + x, z = int(x_str), int(z_str) + grid_cell_size = 2 # Must match client + return [ + f"{x}_{z + grid_cell_size}", + f"{x}_{z - grid_cell_size}", + f"{x + grid_cell_size}_{z}", + f"{x - grid_cell_size}_{z}", + ] + +async def game_loop(): + """Periodically updates game state.""" + while True: + await asyncio.sleep(2) # Update interval + active_nicknames = list(game_state["players"].keys()) + + for nickname in active_nicknames: + player = game_state["players"][nickname] + income = 0 + population = 0 + + player_buildings = {k: v for k, v in game_state["buildings"].items() if v["owner"] == nickname} + + # Calculate base income and population + for building in player_buildings.values(): + b_type = building["type"] + if b_type == 'residential': + population += building_data['residential']['population'] + elif b_type == 'commercial': + income += building_data['commercial']['income'] + elif b_type == 'industrial': + income += building_data['industrial']['income'] + elif b_type == 'powerplant': + income += building_data['powerplant']['income'] + + # Calculate park adjacency bonuses + parks = {k: v for k, v in player_buildings.items() if v["type"] == 'park'} + for park_key in parks: + for neighbor_key in get_neighbors(park_key): + neighbor = player_buildings.get(neighbor_key) + if neighbor and neighbor["type"] == 'residential': + population += building_data['park']['population_bonus'] + + player["money"] += income + player["population"] = population + + db_execute("UPDATE players SET money = ?, population = ? WHERE nickname = ?", (player["money"], player["population"], nickname)) + + if game_state["players"]: + await manager.broadcast(json.dumps({ + "type": "players_update", "players": game_state["players"] + })) + +@app.on_event("startup") +async def on_startup(): + init_db() + all_db_buildings = db_fetchall("SELECT key, owner_nickname, type, cost FROM buildings") + for b in all_db_buildings: + game_state["buildings"][b["key"]] = { "owner": b["owner_nickname"], "type": b["type"], "cost": b["cost"] } + logger.info(f"Loaded {len(game_state['buildings'])} buildings from database.") + asyncio.create_task(game_loop()) + +# --- WebSocket Endpoint --- +@app.websocket("/ws/{nickname}") +async def websocket_endpoint(websocket: WebSocket, nickname: str): + await manager.connect(websocket, nickname) + + player_data = db_fetchone("SELECT money, population FROM players WHERE nickname = ?", (nickname,)) + if player_data: + game_state["players"][nickname] = { "money": player_data["money"], "population": player_data["population"] } + else: + initial_money = 1500 + initial_pop = 0 + db_execute("INSERT INTO players (nickname, money, population) VALUES (?, ?, ?)", (nickname, initial_money, initial_pop)) + game_state["players"][nickname] = {"money": initial_money, "population": initial_pop} + + await websocket.send_text(json.dumps({ "type": "full_state", "buildings": game_state["buildings"] })) + await manager.broadcast(json.dumps({ "type": "status_update", "message": f"'{nickname}' has joined the game!" })) + await manager.broadcast(json.dumps({ "type": "players_update", "players": game_state["players"] })) + + try: + while True: + data = await websocket.receive_text() + message = json.loads(data) + player = game_state["players"].get(nickname) + if not player: continue + + action = message.get("action") + pos = message.get("position") + key = f"{pos['x']}_{pos['z']}" + + if action == "build": + build_type = message.get("type") + if build_type not in building_data: continue + cost = building_data[build_type].get('cost', 0) + + if player["money"] >= cost and key not in game_state["buildings"]: + player["money"] -= cost + db_execute("UPDATE players SET money = ? WHERE nickname = ?", (player["money"], nickname)) + + new_building = {"owner": nickname, "type": build_type, "cost": cost} + game_state["buildings"][key] = new_building + db_execute("INSERT INTO buildings (key, owner_nickname, type, cost) VALUES (?, ?, ?, ?)", (key, nickname, build_type, cost)) + + await manager.broadcast(json.dumps({ "type": "build_update", "key": key, "building": new_building, "position": pos })) + await manager.broadcast(json.dumps({ "type": "status_update", "message": f"'{nickname}' built a new {build_type}." })) + + elif action == "remove": + if key in game_state["buildings"] and game_state["buildings"][key]["owner"] == nickname: + removed_building = game_state["buildings"].pop(key) + player["money"] += removed_building["cost"] * 0.5 + db_execute("UPDATE players SET money = ? WHERE nickname = ?", (player["money"], nickname)) + db_execute("DELETE FROM buildings WHERE key = ?", (key,)) + + await manager.broadcast(json.dumps({"type": "remove_update", "key": key, "position": pos})) + await manager.broadcast(json.dumps({ "type": "status_update", "message": f"'{nickname}' removed a building." })) + + except WebSocketDisconnect: + manager.disconnect(nickname) + if nickname in game_state["players"]: + del game_state["players"][nickname] + await manager.broadcast(json.dumps({ "type": "players_update", "players": game_state["players"] })) + await manager.broadcast(json.dumps({ "type": "status_update", "message": f"'{nickname}' has left the game." })) + +@app.get("/") +async def root(): + return FileResponse("index.html") + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..1c5f3af --- /dev/null +++ b/index.html @@ -0,0 +1,407 @@ + + + + + + Tiny Tycoon 3D (Multiplayer) + + + +
+
+

Enter Your Nickname

+ + +
+
+ + + + + + + +