Nano city.
This commit is contained in:
parent
1f2e3b7efe
commit
683604e12b
887
nanocity.py
Normal file
887
nanocity.py
Normal file
@ -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 = """<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>City Tycoon</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="login-screen">
|
||||||
|
<div class="tt-window">
|
||||||
|
<div class="tt-title">CITY TYCOON</div>
|
||||||
|
<input type="text" id="username-input" class="tt-input" placeholder="Enter username" maxlength="20">
|
||||||
|
<button class="tt-button" onclick="login()">START</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="game-screen" style="display:none;">
|
||||||
|
<div id="hud">
|
||||||
|
<div id="player-info"></div>
|
||||||
|
<div id="money-info"></div>
|
||||||
|
</div>
|
||||||
|
<canvas id="game-canvas"></canvas>
|
||||||
|
<div id="toolbar"></div>
|
||||||
|
<div id="chat-container">
|
||||||
|
<div id="chat-messages"></div>
|
||||||
|
<input type="text" id="chat-input" placeholder="Press Enter to chat...">
|
||||||
|
</div>
|
||||||
|
<div id="context-menu" class="context-menu" style="display:none;">
|
||||||
|
<div class="context-item" onclick="renameObject()">Rename</div>
|
||||||
|
<div class="context-item" onclick="demolishObject()">Demolish</div>
|
||||||
|
</div>
|
||||||
|
<div id="rename-dialog" class="tt-window" style="display:none;">
|
||||||
|
<div class="tt-title">RENAME</div>
|
||||||
|
<input type="text" id="rename-input" class="tt-input" maxlength="30">
|
||||||
|
<button class="tt-button" onclick="confirmRename()">OK</button>
|
||||||
|
<button class="tt-button" onclick="cancelRename()">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
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)
|
Loading…
Reference in New Issue
Block a user