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