diff --git a/nanocity.py b/nanocity.py new file mode 100644 index 0000000..62e8fe4 --- /dev/null +++ b/nanocity.py @@ -0,0 +1,887 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.staticfiles import StaticFiles +import sqlite3 +import json +import asyncio +from datetime import datetime +from typing import Dict, Set +import random + +app = FastAPI() + +# Database initialization +def init_db(): + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS users + (username TEXT PRIMARY KEY, money REAL, color TEXT, last_login TEXT)''') + c.execute('''CREATE TABLE IF NOT EXISTS objects + (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, type TEXT, + x INTEGER, y INTEGER, name TEXT, profit_rate REAL)''') + c.execute('''CREATE TABLE IF NOT EXISTS chat + (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, message TEXT, timestamp TEXT)''') + conn.commit() + conn.close() + +init_db() + +# Active connections +connections: Dict[str, WebSocket] = {} +user_cursors: Dict[str, tuple] = {} + +# Economic system +BUILDING_COSTS = { + "house": 50000, "flat": 120000, "store": 80000, "hospital": 250000, + "road": 1000, "tree": 500, "fountain": 3000, "office": 150000 +} + +BUILDING_PROFITS = { + "house": 500, "flat": 1200, "store": 800, "hospital": 2000, + "office": 1500, "road": 0, "tree": 0, "fountain": 0 +} + +STARTING_MONEY = 150000 +COLORS = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A", "#98D8C8", + "#F7DC6F", "#BB8FCE", "#85C1E2", "#F8B739", "#52D7A7"] + +def get_user_color(username): + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("SELECT color FROM users WHERE username=?", (username,)) + result = c.fetchone() + conn.close() + if result: + return result[0] + return random.choice(COLORS) + +def get_or_create_user(username): + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("SELECT * FROM users WHERE username=?", (username,)) + user = c.fetchone() + + if not user: + color = random.choice(COLORS) + c.execute("INSERT INTO users VALUES (?, ?, ?, ?)", + (username, STARTING_MONEY, color, datetime.now().isoformat())) + conn.commit() + user = (username, STARTING_MONEY, color, datetime.now().isoformat()) + + conn.close() + return {"username": user[0], "money": user[1], "color": user[2]} + +def save_object(username, obj_type, x, y, name=""): + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + profit = BUILDING_PROFITS.get(obj_type, 0) + c.execute("INSERT INTO objects (username, type, x, y, name, profit_rate) VALUES (?, ?, ?, ?, ?, ?)", + (username, obj_type, x, y, name, profit)) + conn.commit() + obj_id = c.lastrowid + conn.close() + return obj_id + +def load_objects(): + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("SELECT id, username, type, x, y, name FROM objects") + objects = c.fetchall() + conn.close() + return [{"id": o[0], "username": o[1], "type": o[2], "x": o[3], "y": o[4], "name": o[5]} for o in objects] + +def delete_object(obj_id, username): + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("SELECT username FROM objects WHERE id=?", (obj_id,)) + result = c.fetchone() + if result and result[0] == username: + c.execute("DELETE FROM objects WHERE id=?", (obj_id,)) + conn.commit() + conn.close() + return True + conn.close() + return False + +async def broadcast(message, exclude=None): + for username, ws in connections.items(): + if username != exclude: + try: + await ws.send_json(message) + except: + pass + +# HTML Frontend +HTML_CONTENT = """ + + + + City Tycoon + + + +
+
+
CITY TYCOON
+ + +
+
+ + + +""" + +CSS_CONTENT = """* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Courier New', monospace; +} + +body { + overflow: hidden; + background: #000; +} + +#login-screen { + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #1a1a1a; +} + +.tt-window { + background: #2a2a2a; + border: 2px solid #555; + padding: 20px; + min-width: 300px; +} + +.tt-title { + color: #fff; + font-size: 20px; + font-weight: bold; + margin-bottom: 15px; + text-align: center; +} + +.tt-input { + width: 100%; + background: #000; + border: 2px solid #555; + color: #fff; + padding: 8px; + font-size: 14px; + margin-bottom: 10px; +} + +.tt-button { + background: #444; + border: 2px solid #666; + color: #fff; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + margin: 2px; +} + +.tt-button:hover { + background: #555; +} + +.tt-button:active { + background: #333; +} + +.tt-button:disabled { + background: #222; + color: #555; + cursor: not-allowed; +} + +#game-screen { + width: 100vw; + height: 100vh; + position: relative; +} + +#game-canvas { + display: block; + cursor: crosshair; +} + +#hud { + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + border: 2px solid #555; + padding: 10px; + color: #fff; + font-size: 12px; + z-index: 100; +} + +#toolbar { + position: absolute; + top: 20%; + right: 10px; + width: 150px; + background: rgba(0, 0, 0, 0.8); + border: 2px solid #555; + padding: 5px; + max-height: 70%; + overflow-y: auto; + z-index: 100; +} + +.tool-item { + background: #333; + border: 2px solid #555; + padding: 8px; + margin: 3px 0; + cursor: pointer; + color: #fff; + font-size: 11px; + text-align: center; +} + +.tool-item:hover { + background: #444; +} + +.tool-item.selected { + background: #555; + border-color: #888; +} + +.tool-item.disabled { + background: #222; + color: #555; + cursor: not-allowed; +} + +#chat-container { + position: absolute; + bottom: 10px; + left: 10px; + width: 400px; + height: 150px; + background: rgba(0, 0, 0, 0.7); + border: 2px solid #555; + z-index: 100; +} + +#chat-messages { + height: calc(100% - 30px); + overflow-y: auto; + padding: 5px; + color: #fff; + font-size: 11px; +} + +#chat-input { + width: 100%; + height: 25px; + background: #000; + border: none; + border-top: 2px solid #555; + color: #fff; + padding: 5px; + font-size: 11px; +} + +.context-menu { + position: absolute; + background: #2a2a2a; + border: 2px solid #555; + z-index: 200; +} + +.context-item { + padding: 8px 15px; + color: #fff; + font-size: 12px; + cursor: pointer; +} + +.context-item:hover { + background: #444; +} + +#rename-dialog { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 300; +}""" + +JS_CONTENT = """let ws = null; +let username = ''; +let playerColor = ''; +let playerMoney = 0; +let canvas, ctx; +let camera = { x: 0, y: 0, zoom: 1 }; +let objects = []; +let selectedTool = null; +let isDragging = false; +let dragStart = { x: 0, y: 0 }; +let hoveredTile = null; +let contextMenuObject = null; +const TILE_SIZE = 32; +const RENDER_SECTORS = 12; +let sectorCache = {}; +let otherCursors = {}; + +const TOOLS = [ + { type: 'road', name: 'Road', cost: 1000 }, + { type: 'house', name: 'House', cost: 50000 }, + { type: 'flat', name: 'Flat', cost: 120000 }, + { type: 'store', name: 'Store', cost: 80000 }, + { type: 'office', name: 'Office', cost: 150000 }, + { type: 'hospital', name: 'Hospital', cost: 250000 }, + { type: 'tree', name: 'Tree', cost: 500 }, + { type: 'fountain', name: 'Fountain', cost: 3000 } +]; + +function login() { + const input = document.getElementById('username-input'); + username = input.value.trim(); + if (!username) return; + + document.getElementById('login-screen').style.display = 'none'; + document.getElementById('game-screen').style.display = 'block'; + + initGame(); + connectWebSocket(); +} + +function initGame() { + canvas = document.getElementById('game-canvas'); + ctx = canvas.getContext('2d'); + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + setupToolbar(); + setupEventListeners(); + render(); +} + +function connectWebSocket() { + ws = new WebSocket(`ws://127.0.0.1:8595/ws/${username}`); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + handleMessage(data); + }; + + ws.onclose = () => { + setTimeout(connectWebSocket, 3000); + }; +} + +function handleMessage(data) { + if (data.type === 'init') { + playerColor = data.color; + playerMoney = data.money; + objects = data.objects; + updateHUD(); + updateToolbar(); + } else if (data.type === 'object_placed') { + objects.push(data.object); + if (data.object.username === username) { + playerMoney = data.money; + updateHUD(); + updateToolbar(); + } + } else if (data.type === 'object_deleted') { + objects = objects.filter(o => o.id !== data.id); + if (data.username === username) { + playerMoney = data.money; + updateHUD(); + updateToolbar(); + } + } else if (data.type === 'object_renamed') { + const obj = objects.find(o => o.id === data.id); + if (obj) obj.name = data.name; + } else if (data.type === 'chat') { + addChatMessage(data.username, data.message, data.timestamp); + } else if (data.type === 'cursor') { + otherCursors[data.username] = { x: data.x, y: data.y, color: data.color }; + } else if (data.type === 'money_update') { + if (data.username === username) { + playerMoney = data.money; + updateHUD(); + updateToolbar(); + } + } +} + +function setupToolbar() { + const toolbar = document.getElementById('toolbar'); + toolbar.innerHTML = ''; + + TOOLS.forEach(tool => { + const div = document.createElement('div'); + div.className = 'tool-item'; + div.textContent = `${tool.name}\\n$${tool.cost.toLocaleString()}`; + div.onclick = () => selectTool(tool); + div.dataset.type = tool.type; + toolbar.appendChild(div); + }); +} + +function selectTool(tool) { + if (tool.cost > playerMoney) return; + selectedTool = tool; + updateToolbar(); +} + +function updateToolbar() { + document.querySelectorAll('.tool-item').forEach(item => { + const tool = TOOLS.find(t => t.type === item.dataset.type); + item.classList.remove('selected', 'disabled'); + if (tool.cost > playerMoney) { + item.classList.add('disabled'); + } + if (selectedTool && selectedTool.type === item.dataset.type) { + item.classList.add('selected'); + } + }); +} + +function setupEventListeners() { + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseup', handleMouseUp); + canvas.addEventListener('contextmenu', handleContextMenu); + canvas.addEventListener('wheel', handleWheel); + + const chatInput = document.getElementById('chat-input'); + chatInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && chatInput.value.trim()) { + ws.send(JSON.stringify({ type: 'chat', message: chatInput.value.trim() })); + chatInput.value = ''; + } + }); + + window.addEventListener('resize', () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }); +} + +function handleMouseDown(e) { + if (e.button === 2) { + isDragging = true; + dragStart = { x: e.clientX - camera.x, y: e.clientY - camera.y }; + } else if (e.button === 0 && selectedTool && hoveredTile) { + placeObject(hoveredTile.x, hoveredTile.y); + } +} + +function handleMouseMove(e) { + if (isDragging) { + camera.x = e.clientX - dragStart.x; + camera.y = e.clientY - dragStart.y; + } else { + hoveredTile = screenToTile(e.clientX, e.clientY); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'cursor', + x: hoveredTile ? hoveredTile.x : -999, + y: hoveredTile ? hoveredTile.y : -999 + })); + } + } +} + +function handleMouseUp(e) { + if (e.button === 2) { + isDragging = false; + } +} + +function handleContextMenu(e) { + e.preventDefault(); + const tile = screenToTile(e.clientX, e.clientY); + if (!tile) return; + + const obj = objects.find(o => o.x === tile.x && o.y === tile.y && o.username === username); + if (obj) { + contextMenuObject = obj; + const menu = document.getElementById('context-menu'); + menu.style.display = 'block'; + menu.style.left = e.clientX + 'px'; + menu.style.top = e.clientY + 'px'; + } +} + +function handleWheel(e) { + e.preventDefault(); + const oldZoom = camera.zoom; + camera.zoom *= e.deltaY > 0 ? 0.9 : 1.1; + camera.zoom = Math.max(0.5, Math.min(2, camera.zoom)); + + const zoomRatio = camera.zoom / oldZoom; + camera.x = e.clientX - (e.clientX - camera.x) * zoomRatio; + camera.y = e.clientY - (e.clientY - camera.y) * zoomRatio; +} + +function placeObject(x, y) { + if (!selectedTool || objects.some(o => o.x === x && o.y === y)) return; + + ws.send(JSON.stringify({ + type: 'place_object', + object_type: selectedTool.type, + x: x, + y: y + })); +} + +function renameObject() { + document.getElementById('context-menu').style.display = 'none'; + document.getElementById('rename-dialog').style.display = 'block'; + document.getElementById('rename-input').value = contextMenuObject.name || ''; + document.getElementById('rename-input').focus(); +} + +function demolishObject() { + document.getElementById('context-menu').style.display = 'none'; + ws.send(JSON.stringify({ + type: 'demolish', + id: contextMenuObject.id + })); +} + +function confirmRename() { + const name = document.getElementById('rename-input').value.trim(); + ws.send(JSON.stringify({ + type: 'rename', + id: contextMenuObject.id, + name: name + })); + cancelRename(); +} + +function cancelRename() { + document.getElementById('rename-dialog').style.display = 'none'; +} + +function screenToTile(screenX, screenY) { + const worldX = (screenX - camera.x) / camera.zoom; + const worldY = (screenY - camera.y) / camera.zoom; + return { + x: Math.floor(worldX / TILE_SIZE), + y: Math.floor(worldY / TILE_SIZE) + }; +} + +function tileToScreen(tileX, tileY) { + return { + x: tileX * TILE_SIZE * camera.zoom + camera.x, + y: tileY * TILE_SIZE * camera.zoom + camera.y + }; +} + +function updateHUD() { + document.getElementById('player-info').textContent = `Player: ${username}`; + document.getElementById('money-info').textContent = `Money: $${playerMoney.toLocaleString()}`; +} + +function addChatMessage(user, message, timestamp) { + const messages = document.getElementById('chat-messages'); + const time = timestamp.split('T')[1].substring(0, 5); + const div = document.createElement('div'); + div.textContent = `[${time}] [${user}]: ${message}`; + messages.appendChild(div); + messages.scrollTop = messages.scrollHeight; +} + +function render() { + ctx.fillStyle = '#90EE90'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const startTile = screenToTile(0, 0); + const endTile = screenToTile(canvas.width, canvas.height); + + for (let y = startTile.y - 1; y <= endTile.y + 1; y++) { + for (let x = startTile.x - 1; x <= endTile.x + 1; x++) { + const pos = tileToScreen(x, y); + + ctx.strokeStyle = '#70CC70'; + ctx.lineWidth = 1; + ctx.strokeRect(pos.x, pos.y, TILE_SIZE * camera.zoom, TILE_SIZE * camera.zoom); + + if (hoveredTile && hoveredTile.x === x && hoveredTile.y === y && selectedTool) { + ctx.fillStyle = 'rgba(255, 255, 0, 0.3)'; + ctx.fillRect(pos.x, pos.y, TILE_SIZE * camera.zoom, TILE_SIZE * camera.zoom); + } + } + } + + objects.forEach(obj => { + if (obj.x >= startTile.x - 1 && obj.x <= endTile.x + 1 && + obj.y >= startTile.y - 1 && obj.y <= endTile.y + 1) { + drawObject(obj); + } + }); + + Object.entries(otherCursors).forEach(([user, cursor]) => { + if (cursor.x >= startTile.x && cursor.x <= endTile.x && + cursor.y >= startTile.y && cursor.y <= endTile.y) { + const pos = tileToScreen(cursor.x, cursor.y); + ctx.fillStyle = cursor.color; + ctx.globalAlpha = 0.5; + ctx.fillRect(pos.x, pos.y, TILE_SIZE * camera.zoom, TILE_SIZE * camera.zoom); + ctx.globalAlpha = 1; + } + }); + + requestAnimationFrame(render); +} + +function drawObject(obj) { + const pos = tileToScreen(obj.x, obj.y); + const size = TILE_SIZE * camera.zoom; + const color = obj.username === username ? playerColor : getStoredColor(obj.username); + + ctx.fillStyle = color; + + if (obj.type === 'road') { + ctx.fillStyle = '#555'; + ctx.fillRect(pos.x, pos.y, size, size); + } else if (obj.type === 'house') { + ctx.fillRect(pos.x + size * 0.1, pos.y + size * 0.3, size * 0.8, size * 0.6); + ctx.beginPath(); + ctx.moveTo(pos.x + size * 0.5, pos.y + size * 0.1); + ctx.lineTo(pos.x + size * 0.9, pos.y + size * 0.3); + ctx.lineTo(pos.x + size * 0.1, pos.y + size * 0.3); + ctx.closePath(); + ctx.fill(); + } else if (obj.type === 'flat') { + ctx.fillRect(pos.x + size * 0.1, pos.y + size * 0.1, size * 0.8, size * 0.8); + ctx.fillStyle = darkenColor(color); + ctx.fillRect(pos.x + size * 0.1, pos.y + size * 0.1, size * 0.8, size * 0.15); + } else if (obj.type === 'store') { + ctx.fillRect(pos.x + size * 0.1, pos.y + size * 0.2, size * 0.8, size * 0.7); + ctx.fillStyle = '#FFF'; + ctx.fillRect(pos.x + size * 0.3, pos.y + size * 0.4, size * 0.15, size * 0.3); + } else if (obj.type === 'hospital') { + ctx.fillRect(pos.x + size * 0.1, pos.y + size * 0.2, size * 0.8, size * 0.7); + ctx.fillStyle = '#FFF'; + ctx.fillRect(pos.x + size * 0.4, pos.y + size * 0.35, size * 0.2, size * 0.5); + ctx.fillRect(pos.x + size * 0.25, pos.y + size * 0.5, size * 0.5, size * 0.2); + } else if (obj.type === 'office') { + ctx.fillRect(pos.x + size * 0.15, pos.y + size * 0.15, size * 0.7, size * 0.75); + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 2; j++) { + ctx.fillStyle = '#FFF'; + ctx.fillRect(pos.x + size * (0.25 + j * 0.3), pos.y + size * (0.25 + i * 0.2), size * 0.15, size * 0.12); + } + } + } else if (obj.type === 'tree') { + ctx.fillStyle = '#654321'; + ctx.fillRect(pos.x + size * 0.4, pos.y + size * 0.5, size * 0.2, size * 0.4); + ctx.fillStyle = '#228B22'; + ctx.beginPath(); + ctx.arc(pos.x + size * 0.5, pos.y + size * 0.4, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + } else if (obj.type === 'fountain') { + ctx.fillStyle = '#4682B4'; + ctx.beginPath(); + ctx.arc(pos.x + size * 0.5, pos.y + size * 0.5, size * 0.35, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#87CEEB'; + ctx.beginPath(); + ctx.arc(pos.x + size * 0.5, pos.y + size * 0.5, size * 0.2, 0, Math.PI * 2); + ctx.fill(); + } + + if (obj.name) { + ctx.fillStyle = color; + ctx.font = `${10 * camera.zoom}px monospace`; + ctx.textAlign = 'center'; + ctx.fillText(obj.name, pos.x + size * 0.5, pos.y - 5 * camera.zoom); + } +} + +function getStoredColor(username) { + return '#888'; +} + +function darkenColor(color) { + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + return `rgb(${Math.floor(r * 0.7)}, ${Math.floor(g * 0.7)}, ${Math.floor(b * 0.7)})`; +} + +document.addEventListener('click', (e) => { + if (!e.target.closest('#context-menu') && !e.target.closest('#rename-dialog')) { + document.getElementById('context-menu').style.display = 'none'; + } +});""" + +@app.get("/") +async def get_index(): + return HTMLResponse(content=HTML_CONTENT) + +@app.get("/static/app.js") +async def get_js(): + from fastapi.responses import Response + return Response(content=JS_CONTENT, media_type="application/javascript") + +@app.get("/static/app.css") +async def get_css(): + from fastapi.responses import Response + return Response(content=CSS_CONTENT, media_type="text/css") + +@app.websocket("/ws/{username}") +async def websocket_endpoint(websocket: WebSocket, username: str): + await websocket.accept() + connections[username] = websocket + + user_data = get_or_create_user(username) + objects_data = load_objects() + + await websocket.send_json({ + "type": "init", + "username": username, + "color": user_data["color"], + "money": user_data["money"], + "objects": objects_data + }) + + try: + while True: + data = await websocket.receive_json() + + if data["type"] == "place_object": + cost = BUILDING_COSTS.get(data["object_type"], 0) + if user_data["money"] >= cost: + user_data["money"] -= cost + obj_id = save_object(username, data["object_type"], data["x"], data["y"]) + + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("UPDATE users SET money=? WHERE username=?", (user_data["money"], username)) + conn.commit() + conn.close() + + new_obj = { + "id": obj_id, + "username": username, + "type": data["object_type"], + "x": data["x"], + "y": data["y"], + "name": "" + } + + await broadcast({ + "type": "object_placed", + "object": new_obj, + "money": user_data["money"] + }) + + elif data["type"] == "demolish": + if delete_object(data["id"], username): + await broadcast({"type": "object_deleted", "id": data["id"], "username": username}) + + elif data["type"] == "rename": + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("UPDATE objects SET name=? WHERE id=? AND username=?", + (data["name"], data["id"], username)) + conn.commit() + conn.close() + + await broadcast({"type": "object_renamed", "id": data["id"], "name": data["name"]}) + + elif data["type"] == "chat": + timestamp = datetime.now().isoformat() + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("INSERT INTO chat (username, message, timestamp) VALUES (?, ?, ?)", + (username, data["message"], timestamp)) + conn.commit() + conn.close() + + await broadcast({ + "type": "chat", + "username": username, + "message": data["message"], + "timestamp": timestamp + }) + + elif data["type"] == "cursor": + await broadcast({ + "type": "cursor", + "username": username, + "x": data["x"], + "y": data["y"], + "color": user_data["color"] + }, exclude=username) + + except WebSocketDisconnect: + connections.pop(username, None) + user_cursors.pop(username, None) + +# Economic system background task +async def economic_loop(): + while True: + await asyncio.sleep(300) + conn = sqlite3.connect('city_game.db') + c = conn.cursor() + c.execute("""SELECT username, SUM(profit_rate) as total_profit + FROM objects GROUP BY username""") + profits = c.fetchall() + + for username, profit in profits: + c.execute("SELECT money FROM users WHERE username=?", (username,)) + current_money = c.fetchone()[0] + new_money = current_money + profit + c.execute("UPDATE users SET money=? WHERE username=?", (new_money, username)) + + if username in connections: + await connections[username].send_json({ + "type": "money_update", + "username": username, + "money": new_money + }) + + conn.commit() + conn.close() + +@app.on_event("startup") +async def startup_event(): + asyncio.create_task(economic_loop()) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8595)