Upload 4 files
Browse files- admin.html +31 -0
- index.html +370 -0
- main.py +405 -0
- requirements.txt +2 -0
admin.html
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<title>Void City Admin</title>
|
| 5 |
+
<style>
|
| 6 |
+
body { background: #222; color: white; font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; }
|
| 7 |
+
button { padding: 20px 40px; font-size: 24px; margin: 10px; cursor: pointer; border: none; border-radius: 5px; }
|
| 8 |
+
.start { background: #2ecc71; color: white; }
|
| 9 |
+
.stop { background: #e74c3c; color: white; }
|
| 10 |
+
</style>
|
| 11 |
+
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<h1>Admin Control Panel</h1>
|
| 15 |
+
<button class="start" onclick="send('start_tournament')">Start Tournament</button>
|
| 16 |
+
<button class="stop" onclick="send('stop_tournament')">Stop & Show Results</button>
|
| 17 |
+
|
| 18 |
+
<script>
|
| 19 |
+
// Use the same token as the client for simplicity in this MVP
|
| 20 |
+
// In prod, you'd want a separate admin auth mechanism
|
| 21 |
+
const token = sessionStorage.getItem('game_token');
|
| 22 |
+
const socket = io({ auth: { token: token } });
|
| 23 |
+
|
| 24 |
+
function send(cmd) {
|
| 25 |
+
if(confirm("Are you sure?")) {
|
| 26 |
+
socket.emit('admin_cmd', { cmd: cmd });
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
</script>
|
| 30 |
+
</body>
|
| 31 |
+
</html>
|
index.html
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
+
<title>Void City 3D - Multiplayer</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { box-sizing: border-box; user-select: none; -webkit-user-select: none; touch-action: none; }
|
| 9 |
+
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #1a1a1a; font-family: -apple-system, sans-serif; }
|
| 10 |
+
#gameCanvas { display: block; width: 100%; height: 100%; }
|
| 11 |
+
#uiLayer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
|
| 12 |
+
|
| 13 |
+
#scoreBoard { position: absolute; top: 20px; right: 20px; text-align: right; color: white; text-shadow: 0 2px 4px rgba(0,0,0,0.8); }
|
| 14 |
+
.score-row { margin-bottom: 5px; font-weight: bold; font-size: 16px; display: flex; align-items: center; justify-content: flex-end; }
|
| 15 |
+
.lb-avatar { width: 20px; height: 20px; border-radius: 50%; margin-right: 8px; border: 1px solid white; }
|
| 16 |
+
|
| 17 |
+
#loginScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; pointer-events: auto; }
|
| 18 |
+
.discord-btn { background: #5865F2; color: white; padding: 15px 30px; border-radius: 5px; font-size: 20px; font-weight: bold; border: none; cursor: pointer; }
|
| 19 |
+
|
| 20 |
+
#resultsScreen { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.95); z-index: 200; flex-direction: column; align-items: center; justify-content: center; color: white; pointer-events: auto; }
|
| 21 |
+
.res-entry { font-size: 24px; margin: 10px; display: flex; align-items: center; }
|
| 22 |
+
</style>
|
| 23 |
+
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
|
| 24 |
+
</head>
|
| 25 |
+
<body>
|
| 26 |
+
|
| 27 |
+
<canvas id="gameCanvas"></canvas>
|
| 28 |
+
|
| 29 |
+
<div id="uiLayer">
|
| 30 |
+
<div id="scoreBoard">Connecting...</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div id="loginScreen">
|
| 34 |
+
<h1 style="color:white; font-size:50px; margin-bottom:30px;">VOID CITY 3D</h1>
|
| 35 |
+
<button class="discord-btn" onclick="login()">Login with Discord</button>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div id="resultsScreen">
|
| 39 |
+
<h1>TOURNAMENT ENDED</h1>
|
| 40 |
+
<div id="resultsList"></div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<script>
|
| 44 |
+
const COLORS = { ground: '#95a5a6', road: '#2c3e50', marking: '#f1c40f', crosswalk: '#ecf0f1' };
|
| 45 |
+
const GRID_SIZE = 8;
|
| 46 |
+
const BLOCK_SIZE = 280;
|
| 47 |
+
const ROAD_WIDTH = 90;
|
| 48 |
+
const CELL_SIZE = BLOCK_SIZE + ROAD_WIDTH;
|
| 49 |
+
|
| 50 |
+
// --- AUTH ---
|
| 51 |
+
const urlParams = new URLSearchParams(window.location.search);
|
| 52 |
+
const token = urlParams.get('token') || sessionStorage.getItem('game_token');
|
| 53 |
+
if (token) {
|
| 54 |
+
sessionStorage.setItem('game_token', token);
|
| 55 |
+
document.getElementById('loginScreen').style.display = 'none';
|
| 56 |
+
window.history.replaceState({}, document.title, "/");
|
| 57 |
+
}
|
| 58 |
+
function login() { window.location.href = '/login'; }
|
| 59 |
+
|
| 60 |
+
// --- ENGINE ---
|
| 61 |
+
const canvas = document.getElementById('gameCanvas');
|
| 62 |
+
const ctx = canvas.getContext('2d', { alpha: false });
|
| 63 |
+
let socket;
|
| 64 |
+
let mapConfig = { width: 3000, height: 3000 };
|
| 65 |
+
|
| 66 |
+
const camera = { x: 0, y: 0, zoom: 1 };
|
| 67 |
+
let staticEntities = [];
|
| 68 |
+
let cars = [];
|
| 69 |
+
|
| 70 |
+
// OPTIMIZATION: Cache static player data (name, avatar, color)
|
| 71 |
+
let playerCache = {};
|
| 72 |
+
let players = [];
|
| 73 |
+
let myId = null;
|
| 74 |
+
|
| 75 |
+
const input = { active: false, startX:0, startY:0, currX:0, currY:0 };
|
| 76 |
+
|
| 77 |
+
function resize() {
|
| 78 |
+
canvas.width = window.innerWidth;
|
| 79 |
+
canvas.height = window.innerHeight;
|
| 80 |
+
}
|
| 81 |
+
window.addEventListener('resize', resize);
|
| 82 |
+
resize();
|
| 83 |
+
|
| 84 |
+
if (token) {
|
| 85 |
+
socket = io({ auth: { token: token } });
|
| 86 |
+
socket.on('connect', () => { myId = token; });
|
| 87 |
+
|
| 88 |
+
// New Init Event
|
| 89 |
+
socket.on('init_game', (data) => {
|
| 90 |
+
mapConfig.width = data.width;
|
| 91 |
+
mapConfig.height = data.height;
|
| 92 |
+
staticEntities = data.static_entities;
|
| 93 |
+
// Populate cache
|
| 94 |
+
data.players_meta.forEach(p => playerCache[p.id] = p);
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// Player Management
|
| 98 |
+
socket.on('player_joined', (meta) => { playerCache[meta.id] = meta; });
|
| 99 |
+
socket.on('player_left', (data) => { delete playerCache[data.id]; });
|
| 100 |
+
|
| 101 |
+
// Lean Game Update
|
| 102 |
+
socket.on('game_update', (state) => {
|
| 103 |
+
// Merge dynamic state with static cache
|
| 104 |
+
players = state.players.map(p => {
|
| 105 |
+
const meta = playerCache[p.id] || { username: 'Unknown', color: '#fff', avatar: '' };
|
| 106 |
+
return { ...p, ...meta };
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
cars = state.cars;
|
| 110 |
+
|
| 111 |
+
if(state.removed.length > 0) {
|
| 112 |
+
staticEntities = staticEntities.filter(e => !state.removed.includes(e.id));
|
| 113 |
+
}
|
| 114 |
+
updateUI();
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
socket.on('tournament_reset', (data) => {
|
| 118 |
+
staticEntities = data.static_entities;
|
| 119 |
+
document.getElementById('resultsScreen').style.display = 'none';
|
| 120 |
+
});
|
| 121 |
+
socket.on('tournament_end', (data) => {
|
| 122 |
+
const list = document.getElementById('resultsList');
|
| 123 |
+
list.innerHTML = data.results.map((p, i) => `
|
| 124 |
+
<div class="res-entry">#${i+1} <img src="${p.avatar}" style="width:30px;border-radius:50%;margin:0 10px">${p.username}: ${p.score}</div>
|
| 125 |
+
`).join('');
|
| 126 |
+
document.getElementById('resultsScreen').style.display = 'flex';
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// --- INPUT ---
|
| 131 |
+
function sendInput() {
|
| 132 |
+
if (!socket || !input.active) {
|
| 133 |
+
if(socket) socket.emit('input_data', { angle: 0, force: 0 });
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
const dx = input.currX - input.startX;
|
| 137 |
+
const dy = input.currY - input.startY;
|
| 138 |
+
const angle = Math.atan2(dy, dx);
|
| 139 |
+
const force = Math.min(Math.hypot(dx, dy) / 50, 1);
|
| 140 |
+
socket.emit('input_data', { angle, force });
|
| 141 |
+
}
|
| 142 |
+
setInterval(sendInput, 50);
|
| 143 |
+
|
| 144 |
+
function handleStart(x, y) { input.active = true; input.startX = x; input.startY = y; input.currX = x; input.currY = y; }
|
| 145 |
+
function handleMove(x, y) { if(input.active) { input.currX = x; input.currY = y; } }
|
| 146 |
+
function handleEnd() { input.active = false; }
|
| 147 |
+
|
| 148 |
+
canvas.addEventListener('mousedown', e => handleStart(e.clientX, e.clientY));
|
| 149 |
+
window.addEventListener('mousemove', e => handleMove(e.clientX, e.clientY));
|
| 150 |
+
window.addEventListener('mouseup', handleEnd);
|
| 151 |
+
canvas.addEventListener('touchstart', e => { e.preventDefault(); handleStart(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false});
|
| 152 |
+
canvas.addEventListener('touchmove', e => { e.preventDefault(); handleMove(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false});
|
| 153 |
+
canvas.addEventListener('touchend', handleEnd);
|
| 154 |
+
|
| 155 |
+
// --- DRAWING ---
|
| 156 |
+
function drawCityFloor() {
|
| 157 |
+
ctx.fillStyle = COLORS.ground;
|
| 158 |
+
ctx.fillRect(0, 0, mapConfig.width, mapConfig.height);
|
| 159 |
+
|
| 160 |
+
for(let iy=0; iy<=GRID_SIZE; iy++) {
|
| 161 |
+
const y = iy * CELL_SIZE;
|
| 162 |
+
ctx.fillStyle = COLORS.road;
|
| 163 |
+
ctx.fillRect(0, y - ROAD_WIDTH/2, mapConfig.width, ROAD_WIDTH);
|
| 164 |
+
ctx.strokeStyle = COLORS.marking; ctx.lineWidth = 2; ctx.setLineDash([20, 20]);
|
| 165 |
+
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(mapConfig.width, y); ctx.stroke(); ctx.setLineDash([]);
|
| 166 |
+
for(let ix=0; ix<=GRID_SIZE; ix++) {
|
| 167 |
+
const x = ix * CELL_SIZE; ctx.fillStyle = COLORS.crosswalk;
|
| 168 |
+
for(let k=-ROAD_WIDTH/2; k<ROAD_WIDTH/2; k+=10) {
|
| 169 |
+
ctx.fillRect(x - ROAD_WIDTH/2 - 10, y + k, 8, 5); ctx.fillRect(x + ROAD_WIDTH/2 + 2, y + k, 8, 5);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
for(let ix=0; ix<=GRID_SIZE; ix++) {
|
| 174 |
+
const x = ix * CELL_SIZE;
|
| 175 |
+
ctx.fillStyle = COLORS.road; ctx.fillRect(x - ROAD_WIDTH/2, 0, ROAD_WIDTH, mapConfig.height);
|
| 176 |
+
ctx.strokeStyle = COLORS.marking; ctx.lineWidth = 2; ctx.setLineDash([20, 20]);
|
| 177 |
+
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, mapConfig.height); ctx.stroke(); ctx.setLineDash([]);
|
| 178 |
+
for(let iy=0; iy<=GRID_SIZE; iy++) {
|
| 179 |
+
const y = iy * CELL_SIZE; ctx.fillStyle = COLORS.crosswalk;
|
| 180 |
+
for(let k=-ROAD_WIDTH/2; k<ROAD_WIDTH/2; k+=10) {
|
| 181 |
+
ctx.fillRect(x + k, y - ROAD_WIDTH/2 - 10, 5, 8); ctx.fillRect(x + k, y + ROAD_WIDTH/2 + 2, 5, 8);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function drawBox(ctx, x, y, w, l, h, rotation, color, rx, ry, isFalling) {
|
| 188 |
+
const hw = w/2; const hl = l/2;
|
| 189 |
+
const b_tl = {x: -hw, y: -hl}; const b_tr = {x: hw, y: -hl};
|
| 190 |
+
const b_br = {x: hw, y: hl}; const b_bl = {x: -hw, y: hl};
|
| 191 |
+
const r_tl = {x: b_tl.x + rx, y: b_tl.y + ry}; const r_tr = {x: b_tr.x + rx, y: b_tr.y + ry};
|
| 192 |
+
const r_br = {x: b_br.x + rx, y: b_br.y + ry}; const r_bl = {x: b_bl.x + rx, y: b_bl.y + ry};
|
| 193 |
+
|
| 194 |
+
const drawFace = (p1, p2, p3, p4, c) => {
|
| 195 |
+
ctx.fillStyle = c; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y);
|
| 196 |
+
ctx.lineTo(p3.x, p3.y); ctx.lineTo(p4.x, p4.y); ctx.closePath(); ctx.fill();
|
| 197 |
+
};
|
| 198 |
+
drawFace(b_tl, r_tl, r_tr, b_tr, color); drawFace(b_tr, r_tr, r_br, b_br, color);
|
| 199 |
+
drawFace(b_br, r_br, r_bl, b_bl, color); drawFace(b_bl, r_bl, r_tl, b_tl, color);
|
| 200 |
+
|
| 201 |
+
ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(r_tl.x, r_tl.y); ctx.lineTo(r_tr.x, r_tr.y);
|
| 202 |
+
ctx.lineTo(r_br.x, r_br.y); ctx.lineTo(r_bl.x, r_bl.y); ctx.closePath(); ctx.fill();
|
| 203 |
+
ctx.fillStyle = 'rgba(255,255,255,0.1)'; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.lineWidth = 1; ctx.stroke();
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function draw3DEntity(ent, falling) {
|
| 207 |
+
ctx.save(); ctx.translate(ent.x, ent.y);
|
| 208 |
+
const h = ent.h || 10; const TILT_FACTOR = 1.2; const PERSPECTIVE_X = 0.0003;
|
| 209 |
+
const rx_screen = (ent.x - camera.x) * (h * PERSPECTIVE_X);
|
| 210 |
+
const ry_screen = -h * TILT_FACTOR;
|
| 211 |
+
const ang = -(ent.rotation || 0);
|
| 212 |
+
const rx = rx_screen * Math.cos(ang) - ry_screen * Math.sin(ang);
|
| 213 |
+
const ry = rx_screen * Math.sin(ang) + ry_screen * Math.cos(ang);
|
| 214 |
+
|
| 215 |
+
if (ent.type === 'car') {
|
| 216 |
+
ctx.rotate(ent.rotation || 0);
|
| 217 |
+
if (!falling) {
|
| 218 |
+
ctx.fillStyle = '#111';
|
| 219 |
+
const wx = ent.w/2 - 8; const wy = ent.l/2;
|
| 220 |
+
ctx.fillRect(wx, -wy-2, 6, 4); ctx.fillRect(wx, wy-2, 6, 4);
|
| 221 |
+
ctx.fillRect(-wx, -wy-2, 6, 4); ctx.fillRect(-wx, wy-2, 6, 4);
|
| 222 |
+
}
|
| 223 |
+
drawBox(ctx, 0, 0, ent.w, ent.l, ent.h, 0, ent.color, rx, ry, falling);
|
| 224 |
+
|
| 225 |
+
const ch = ent.cabinH || 10;
|
| 226 |
+
const rx_screen_cab = (ent.x - camera.x) * (ch * PERSPECTIVE_X);
|
| 227 |
+
const ry_screen_cab = -ch * TILT_FACTOR;
|
| 228 |
+
const rx_cab = rx_screen_cab * Math.cos(ang) - ry_screen_cab * Math.sin(ang);
|
| 229 |
+
const ry_cab = rx_screen_cab * Math.sin(ang) + ry_screen_cab * Math.cos(ang);
|
| 230 |
+
|
| 231 |
+
ctx.translate(rx, ry);
|
| 232 |
+
const cw = ent.cabinW; const cl = ent.cabinL;
|
| 233 |
+
const hw = cw/2; const hl = cl/2;
|
| 234 |
+
const b_tl = {x: -hw, y: -hl}; const b_tr = {x: hw, y: -hl};
|
| 235 |
+
const b_br = {x: hw, y: hl}; const b_bl = {x: -hw, y: hl};
|
| 236 |
+
const r_tl = {x: b_tl.x + rx_cab, y: b_tl.y + ry_cab}; const r_tr = {x: b_tr.x + rx_cab, y: b_tr.y + ry_cab};
|
| 237 |
+
const r_br = {x: b_br.x + rx_cab, y: b_br.y + ry_cab}; const r_bl = {x: b_bl.x + rx_cab, y: b_bl.y + ry_cab};
|
| 238 |
+
|
| 239 |
+
const drawPoly = (pts, color) => {
|
| 240 |
+
ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
|
| 241 |
+
for(let i=1; i<pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); ctx.closePath(); ctx.fill();
|
| 242 |
+
};
|
| 243 |
+
drawPoly([b_tl, r_tl, r_tr, b_tr], '#34495e'); drawPoly([b_tr, r_tr, r_br, b_br], '#34495e');
|
| 244 |
+
drawPoly([b_br, r_br, r_bl, b_bl], '#34495e'); drawPoly([b_bl, r_bl, r_tl, b_tl], '#34495e');
|
| 245 |
+
drawPoly([r_tl, r_tr, r_br, r_bl], '#34495e'); ctx.fillStyle = 'rgba(255,255,255,0.1)'; ctx.fill();
|
| 246 |
+
|
| 247 |
+
} else if (ent.type === 'building') {
|
| 248 |
+
ctx.rotate(ent.rotation || 0);
|
| 249 |
+
const w = ent.w; const l = ent.l; const hw = w/2; const hl = l/2;
|
| 250 |
+
const b_tl = {x: -hw, y: -hl}; const b_tr = {x: hw, y: -hl};
|
| 251 |
+
const b_br = {x: hw, y: hl}; const b_bl = {x: -hw, y: hl};
|
| 252 |
+
const r_tl = {x: b_tl.x + rx, y: b_tl.y + ry}; const r_tr = {x: b_tr.x + rx, y: b_tr.y + ry};
|
| 253 |
+
const r_br = {x: b_br.x + rx, y: b_br.y + ry}; const r_bl = {x: b_bl.x + rx, y: b_bl.y + ry};
|
| 254 |
+
|
| 255 |
+
const drawFace = (p1, p2, p3, p4, color, isSide) => {
|
| 256 |
+
ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y);
|
| 257 |
+
ctx.lineTo(p3.x, p3.y); ctx.lineTo(p4.x, p4.y); ctx.closePath(); ctx.fill();
|
| 258 |
+
};
|
| 259 |
+
const sideCol = ent.sideColor || ent.color;
|
| 260 |
+
drawFace(b_tl, r_tl, r_tr, b_tr, sideCol, false); drawFace(b_tr, r_tr, r_br, b_br, sideCol, true);
|
| 261 |
+
drawFace(b_br, r_br, r_bl, b_bl, sideCol, true); drawFace(b_bl, r_bl, r_tl, b_tl, sideCol, true);
|
| 262 |
+
ctx.fillStyle = ent.color; ctx.beginPath(); ctx.moveTo(r_tl.x, r_tl.y); ctx.lineTo(r_tr.x, r_tr.y);
|
| 263 |
+
ctx.lineTo(r_br.x, r_br.y); ctx.lineTo(r_bl.x, r_bl.y); ctx.closePath(); ctx.fill();
|
| 264 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 2; ctx.stroke();
|
| 265 |
+
|
| 266 |
+
} else {
|
| 267 |
+
const rx = (ent.x - camera.x) * (ent.h * PERSPECTIVE_X);
|
| 268 |
+
const ry = -ent.h * TILT_FACTOR;
|
| 269 |
+
ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.arc(0,0, ent.r, 0, Math.PI*2); ctx.fill();
|
| 270 |
+
|
| 271 |
+
if(ent.type === 'rock') {
|
| 272 |
+
ctx.fillStyle = ent.color; ctx.beginPath();
|
| 273 |
+
ctx.moveTo(0, -ent.r + ry); ctx.lineTo(ent.r, 0 + ry);
|
| 274 |
+
ctx.lineTo(0, ent.r + ry); ctx.lineTo(-ent.r, 0 + ry); ctx.fill();
|
| 275 |
+
} else if (ent.type === 'flower') {
|
| 276 |
+
ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 2;
|
| 277 |
+
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(rx, ry); ctx.stroke();
|
| 278 |
+
ctx.fillStyle = ent.color; ctx.beginPath(); ctx.arc(rx, ry, ent.r, 0, Math.PI*2); ctx.fill();
|
| 279 |
+
ctx.fillStyle = 'yellow'; ctx.beginPath(); ctx.arc(rx, ry, ent.r/2, 0, Math.PI*2); ctx.fill();
|
| 280 |
+
} else if (ent.type === 'tree') {
|
| 281 |
+
const layers = 4; ctx.strokeStyle = '#5d4037'; ctx.lineWidth = ent.r * 0.4;
|
| 282 |
+
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(rx*0.5, ry*0.5); ctx.stroke();
|
| 283 |
+
for(let i=0; i<layers; i++) {
|
| 284 |
+
const ratio = i / (layers-1);
|
| 285 |
+
const lrx = rx * (0.2 + ratio * 0.8); const lry = ry * (0.2 + ratio * 0.8);
|
| 286 |
+
ctx.fillStyle = i % 2 === 0 ? ent.color : '#2ecc71';
|
| 287 |
+
const size = ent.r * (1.2 - ratio * 0.4);
|
| 288 |
+
ctx.beginPath(); ctx.arc(lrx, lry, size, 0, Math.PI*2); ctx.fill();
|
| 289 |
+
}
|
| 290 |
+
} else {
|
| 291 |
+
ctx.lineWidth = ent.r * 2; ctx.strokeStyle = ent.sideColor || ent.color; ctx.lineCap = 'round';
|
| 292 |
+
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(rx, ry); ctx.stroke();
|
| 293 |
+
ctx.fillStyle = ent.color; ctx.beginPath(); ctx.arc(rx, ry, ent.r, 0, Math.PI*2); ctx.fill();
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
ctx.restore();
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
function render() {
|
| 300 |
+
if (!socket) { requestAnimationFrame(render); return; }
|
| 301 |
+
|
| 302 |
+
ctx.fillStyle = '#111';
|
| 303 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 304 |
+
|
| 305 |
+
const me = players.find(p => p.id === myId);
|
| 306 |
+
if (me) {
|
| 307 |
+
camera.x += (me.x - camera.x) * 0.1;
|
| 308 |
+
camera.y += (me.y - camera.y) * 0.1;
|
| 309 |
+
const targetZoom = Math.max(0.3, Math.min(1.0, 400 / (me.r * 2)));
|
| 310 |
+
camera.zoom += (targetZoom - camera.zoom) * 0.05;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
ctx.save();
|
| 314 |
+
ctx.translate(canvas.width/2, canvas.height/2);
|
| 315 |
+
ctx.scale(camera.zoom, camera.zoom);
|
| 316 |
+
ctx.translate(-camera.x, -camera.y);
|
| 317 |
+
|
| 318 |
+
drawCityFloor();
|
| 319 |
+
|
| 320 |
+
// Draw Players
|
| 321 |
+
players.forEach(p => {
|
| 322 |
+
if(!p.alive) return;
|
| 323 |
+
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
|
| 324 |
+
ctx.fillStyle = 'black'; ctx.fill();
|
| 325 |
+
ctx.strokeStyle = p.color; ctx.lineWidth = 4; ctx.stroke();
|
| 326 |
+
ctx.fillStyle = 'white'; ctx.font = `bold ${Math.max(14, p.r/2)}px Arial`;
|
| 327 |
+
ctx.textAlign = 'center'; ctx.fillText(p.username, p.x, p.y - p.r - 10);
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
// Clip holes
|
| 331 |
+
ctx.save();
|
| 332 |
+
ctx.beginPath();
|
| 333 |
+
ctx.rect(camera.x - (canvas.width/camera.zoom), camera.y - (canvas.height/camera.zoom), canvas.width*2/camera.zoom, canvas.height*2/camera.zoom);
|
| 334 |
+
players.forEach(p => { if(p.alive) { ctx.moveTo(p.x, p.y); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2, true); } });
|
| 335 |
+
ctx.clip();
|
| 336 |
+
|
| 337 |
+
// Draw Ents
|
| 338 |
+
const viewW = (canvas.width / camera.zoom) / 2 + 200;
|
| 339 |
+
const viewH = (canvas.height / camera.zoom) / 2 + 200;
|
| 340 |
+
const visibleStatic = staticEntities.filter(e => Math.abs(e.x - camera.x) < viewW && Math.abs(e.y - camera.y) < viewH);
|
| 341 |
+
const allEnts = [...visibleStatic, ...cars];
|
| 342 |
+
allEnts.sort((a,b) => (a.y + (a.l||0)/2) - (b.y + (b.l||0)/2));
|
| 343 |
+
allEnts.forEach(ent => draw3DEntity(ent, false));
|
| 344 |
+
|
| 345 |
+
ctx.restore(); ctx.restore();
|
| 346 |
+
|
| 347 |
+
// Joystick
|
| 348 |
+
if (input.active) {
|
| 349 |
+
ctx.beginPath(); ctx.arc(input.startX, input.startY, 50, 0, Math.PI*2);
|
| 350 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 2; ctx.stroke();
|
| 351 |
+
ctx.beginPath(); ctx.arc(input.currX, input.currY, 20, 0, Math.PI*2);
|
| 352 |
+
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fill();
|
| 353 |
+
}
|
| 354 |
+
requestAnimationFrame(render);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function updateUI() {
|
| 358 |
+
const sb = document.getElementById('scoreBoard');
|
| 359 |
+
const sorted = [...players].sort((a,b) => b.score - a.score).slice(0, 5);
|
| 360 |
+
sb.innerHTML = sorted.map(p => `
|
| 361 |
+
<div class="score-row">
|
| 362 |
+
${p.avatar ? `<img src="${p.avatar}" class="lb-avatar">` : ''}
|
| 363 |
+
${p.username}: ${p.score}
|
| 364 |
+
</div>
|
| 365 |
+
`).join('');
|
| 366 |
+
}
|
| 367 |
+
render();
|
| 368 |
+
</script>
|
| 369 |
+
</body>
|
| 370 |
+
</html>
|
main.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import asyncio
|
| 4 |
+
import math
|
| 5 |
+
import uuid
|
| 6 |
+
import random
|
| 7 |
+
import time
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
from aiohttp import web, ClientSession
|
| 10 |
+
import socketio
|
| 11 |
+
|
| 12 |
+
# --- CONFIGURATION ---
|
| 13 |
+
PORT = int(os.getenv('SERVER_PORT', 8080))
|
| 14 |
+
ADMIN_PATH = os.getenv('ADMIN_PATH', '/secret-admin-panel')
|
| 15 |
+
|
| 16 |
+
# !!! REPLACE WITH YOUR DISCORD APP DETAILS !!!
|
| 17 |
+
DISCORD_CLIENT_ID = "1457974183383535649"
|
| 18 |
+
DISCORD_CLIENT_SECRET = "_RoLp-VIb1nC7IOlZd_zvPWHnG4cNMbB"
|
| 19 |
+
DISCORD_REDIRECT_URI = "http://212.227.65.132:15080/callback"
|
| 20 |
+
|
| 21 |
+
GAME_TICK_RATE = 30
|
| 22 |
+
GRID_SIZE = 8
|
| 23 |
+
BLOCK_SIZE = 280
|
| 24 |
+
ROAD_WIDTH = 90
|
| 25 |
+
CELL_SIZE = BLOCK_SIZE + ROAD_WIDTH
|
| 26 |
+
MAP_WIDTH = GRID_SIZE * CELL_SIZE
|
| 27 |
+
MAP_HEIGHT = GRID_SIZE * CELL_SIZE
|
| 28 |
+
|
| 29 |
+
# --- ASSET DEFINITIONS ---
|
| 30 |
+
ENTITY_TYPES = {
|
| 31 |
+
'ROCK': {'r': 8, 'h': 8, 'color': '#7f8c8d', 'score': 1, 'type': 'rock'},
|
| 32 |
+
'FLOWER': {'r': 6, 'h': 4, 'color': '#e84393', 'score': 1, 'type': 'flower'},
|
| 33 |
+
'FENCE': {'w': 20, 'l': 4, 'h': 10, 'color': '#8e44ad', 'score': 1, 'type': 'building'},
|
| 34 |
+
'HYDRANT': {'r': 6, 'h': 12, 'color': '#e74c3c', 'sideColor': '#c0392b', 'score': 1, 'type': 'cylinder'},
|
| 35 |
+
'POST': {'r': 4, 'h': 40, 'color': '#bdc3c7', 'sideColor': '#7f8c8d', 'score': 2, 'type': 'cylinder'},
|
| 36 |
+
'BUSH': {'r': 12, 'h': 10, 'color': '#2ecc71', 'score': 3, 'type': 'bush'},
|
| 37 |
+
'TREE': {'r': 18, 'h': 70, 'color': '#27ae60', 'score': 5, 'type': 'tree'},
|
| 38 |
+
'CAR': {'w': 48, 'l': 24, 'h': 12, 'cabinW': 28, 'cabinL': 20, 'cabinH': 10, 'color': '#e74c3c', 'score': 10, 'type': 'car', 'mobile': True},
|
| 39 |
+
'HOUSE_S': {'w': 70, 'l': 70, 'h': 60, 'color': '#e67e22', 'sideColor': '#d35400', 'score': 50, 'type': 'building', 'windows': True},
|
| 40 |
+
'HOUSE_M': {'w': 100, 'l': 90, 'h': 90, 'color': '#3498db', 'sideColor': '#2980b9', 'score': 100, 'type': 'building', 'windows': True},
|
| 41 |
+
'OFFICE': {'w': 110, 'l': 110, 'h': 160, 'color': '#9b59b6', 'sideColor': '#8e44ad', 'score': 200, 'type': 'building', 'windows': True},
|
| 42 |
+
'TOWER': {'w': 130, 'l': 130, 'h': 280, 'color': '#34495e', 'sideColor': '#2c3e50', 'score': 300, 'type': 'building', 'windows': True}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
sio = socketio.AsyncServer(async_mode='aiohttp', cors_allowed_origins='*')
|
| 46 |
+
app = web.Application()
|
| 47 |
+
sio.attach(app)
|
| 48 |
+
|
| 49 |
+
class GameState:
|
| 50 |
+
def __init__(self):
|
| 51 |
+
self.players = {}
|
| 52 |
+
self.sockets = {}
|
| 53 |
+
self.static_entities = []
|
| 54 |
+
# Spatial Hash Grid for Static Entities: key=(gridX, gridY), val=[entities]
|
| 55 |
+
self.static_grid = defaultdict(list)
|
| 56 |
+
self.dynamic_entities = []
|
| 57 |
+
self.removed_entities = []
|
| 58 |
+
self.active = True
|
| 59 |
+
self.user_data_file = 'user_data.json'
|
| 60 |
+
self.persistent_data = self.load_data()
|
| 61 |
+
self.generate_map()
|
| 62 |
+
|
| 63 |
+
def load_data(self):
|
| 64 |
+
if os.path.exists(self.user_data_file):
|
| 65 |
+
try:
|
| 66 |
+
with open(self.user_data_file, 'r') as f: return json.load(f)
|
| 67 |
+
except: return {}
|
| 68 |
+
return {}
|
| 69 |
+
|
| 70 |
+
def save_data(self):
|
| 71 |
+
with open(self.user_data_file, 'w') as f:
|
| 72 |
+
json.dump(self.persistent_data, f, indent=2)
|
| 73 |
+
|
| 74 |
+
def generate_map(self):
|
| 75 |
+
self.static_entities = []
|
| 76 |
+
self.static_grid.clear()
|
| 77 |
+
self.dynamic_entities = []
|
| 78 |
+
|
| 79 |
+
# Grid Generation
|
| 80 |
+
for ix in range(GRID_SIZE + 1):
|
| 81 |
+
for iy in range(GRID_SIZE + 1):
|
| 82 |
+
x = ix * CELL_SIZE
|
| 83 |
+
y = iy * CELL_SIZE
|
| 84 |
+
if ix < GRID_SIZE and iy < GRID_SIZE:
|
| 85 |
+
self.generate_block(x + ROAD_WIDTH/2, y + ROAD_WIDTH/2)
|
| 86 |
+
|
| 87 |
+
# Car Generation
|
| 88 |
+
lane_offset = ROAD_WIDTH / 4
|
| 89 |
+
for _ in range(60):
|
| 90 |
+
self.spawn_car(lane_offset)
|
| 91 |
+
|
| 92 |
+
def add_entity(self, x, y, template, **kwargs):
|
| 93 |
+
ent = template.copy()
|
| 94 |
+
ent.update(kwargs)
|
| 95 |
+
ent['x'] = x
|
| 96 |
+
ent['y'] = y
|
| 97 |
+
ent['id'] = str(uuid.uuid4())
|
| 98 |
+
ent['startX'] = x
|
| 99 |
+
ent['startY'] = y
|
| 100 |
+
ent['varSeed'] = random.random()
|
| 101 |
+
ent['rotation'] = kwargs.get('rotation', 0)
|
| 102 |
+
|
| 103 |
+
if ent['type'] in ['building', 'car']:
|
| 104 |
+
ent['radius'] = math.hypot(ent['w']/2, ent.get('l', ent['w'])/2)
|
| 105 |
+
else:
|
| 106 |
+
ent['radius'] = ent['r']
|
| 107 |
+
|
| 108 |
+
if ent['type'] == 'flower':
|
| 109 |
+
ent['color'] = random.choice(['#e84393', '#fd79a8', '#00b894', '#fab1a0'])
|
| 110 |
+
|
| 111 |
+
# Add to main list AND spatial grid
|
| 112 |
+
self.static_entities.append(ent)
|
| 113 |
+
gx = int(x // CELL_SIZE)
|
| 114 |
+
gy = int(y // CELL_SIZE)
|
| 115 |
+
self.static_grid[(gx, gy)].append(ent)
|
| 116 |
+
|
| 117 |
+
def get_nearby_static(self, x, y):
|
| 118 |
+
# Return entities in the 3x3 grid cells around the point
|
| 119 |
+
gx = int(x // CELL_SIZE)
|
| 120 |
+
gy = int(y // CELL_SIZE)
|
| 121 |
+
nearby = []
|
| 122 |
+
for ix in range(gx - 1, gx + 2):
|
| 123 |
+
for iy in range(gy - 1, gy + 2):
|
| 124 |
+
nearby.extend(self.static_grid[(ix, iy)])
|
| 125 |
+
return nearby
|
| 126 |
+
|
| 127 |
+
def generate_block(self, bx, by):
|
| 128 |
+
cx = bx + BLOCK_SIZE/2
|
| 129 |
+
cy = by + BLOCK_SIZE/2
|
| 130 |
+
b_type = random.random()
|
| 131 |
+
|
| 132 |
+
if b_type < 0.2: # Park
|
| 133 |
+
inset = 10
|
| 134 |
+
sz = BLOCK_SIZE - inset*2
|
| 135 |
+
for k in range(0, int(sz), 25):
|
| 136 |
+
self.add_entity(bx + inset + k, by + inset, ENTITY_TYPES['FENCE'])
|
| 137 |
+
self.add_entity(bx + inset + k, by + BLOCK_SIZE - inset, ENTITY_TYPES['FENCE'])
|
| 138 |
+
for k in range(0, int(sz), 25):
|
| 139 |
+
self.add_entity(bx + inset, by + inset + k, ENTITY_TYPES['FENCE'], w=5, l=20)
|
| 140 |
+
self.add_entity(bx + BLOCK_SIZE - inset, by + inset + k, ENTITY_TYPES['FENCE'], w=5, l=20)
|
| 141 |
+
|
| 142 |
+
for _ in range(10):
|
| 143 |
+
ox = random.random() * (sz-40) + 20
|
| 144 |
+
oy = random.random() * (sz-40) + 20
|
| 145 |
+
tmpl = ENTITY_TYPES['TREE'] if random.random() > 0.6 else ENTITY_TYPES['FLOWER']
|
| 146 |
+
self.add_entity(bx+inset+ox, by+inset+oy, tmpl)
|
| 147 |
+
self.add_entity(cx, cy, ENTITY_TYPES['HYDRANT'])
|
| 148 |
+
|
| 149 |
+
elif b_type < 0.6: # Residential
|
| 150 |
+
q = BLOCK_SIZE/4
|
| 151 |
+
self.add_entity(cx - q, cy - q, ENTITY_TYPES['HOUSE_S'])
|
| 152 |
+
self.add_entity(cx + q, cy - q, ENTITY_TYPES['HOUSE_M'])
|
| 153 |
+
self.add_entity(cx - q, cy + q, ENTITY_TYPES['HOUSE_M'])
|
| 154 |
+
self.add_entity(cx + q, cy + q, ENTITY_TYPES['HOUSE_S'])
|
| 155 |
+
self.add_entity(cx, cy, ENTITY_TYPES['TREE'])
|
| 156 |
+
self.add_entity(bx + 10, cy, ENTITY_TYPES['BUSH'])
|
| 157 |
+
self.add_entity(bx + BLOCK_SIZE - 10, cy, ENTITY_TYPES['BUSH'])
|
| 158 |
+
|
| 159 |
+
else: # Downtown
|
| 160 |
+
if random.random() > 0.5:
|
| 161 |
+
self.add_entity(cx, cy, ENTITY_TYPES['TOWER'])
|
| 162 |
+
else:
|
| 163 |
+
self.add_entity(cx - 50, cy, ENTITY_TYPES['OFFICE'])
|
| 164 |
+
self.add_entity(cx + 50, cy, ENTITY_TYPES['OFFICE'])
|
| 165 |
+
|
| 166 |
+
self.add_entity(bx + 20, by + 20, ENTITY_TYPES['POST'])
|
| 167 |
+
self.add_entity(bx + BLOCK_SIZE - 20, by + 20, ENTITY_TYPES['POST'])
|
| 168 |
+
self.add_entity(bx + 20, by + BLOCK_SIZE - 20, ENTITY_TYPES['POST'])
|
| 169 |
+
self.add_entity(bx + BLOCK_SIZE - 20, by + BLOCK_SIZE - 20, ENTITY_TYPES['POST'])
|
| 170 |
+
|
| 171 |
+
def spawn_car(self, offset):
|
| 172 |
+
orient = 0 if random.random() > 0.5 else 1
|
| 173 |
+
if orient == 0:
|
| 174 |
+
iy = random.randint(0, GRID_SIZE)
|
| 175 |
+
y = iy * CELL_SIZE
|
| 176 |
+
x = random.random() * MAP_WIDTH
|
| 177 |
+
dir = 1 if random.random() > 0.5 else -1
|
| 178 |
+
y += dir * offset
|
| 179 |
+
rot = 0 if dir == 1 else math.pi
|
| 180 |
+
else:
|
| 181 |
+
ix = random.randint(0, GRID_SIZE)
|
| 182 |
+
x = ix * CELL_SIZE
|
| 183 |
+
y = random.random() * MAP_HEIGHT
|
| 184 |
+
dir = 1 if random.random() > 0.5 else -1
|
| 185 |
+
x += dir * offset
|
| 186 |
+
rot = math.pi/2 if dir == 1 else -math.pi/2
|
| 187 |
+
|
| 188 |
+
car = ENTITY_TYPES['CAR'].copy()
|
| 189 |
+
car.update({
|
| 190 |
+
'x': x, 'y': y, 'rotation': rot, 'id': str(uuid.uuid4()),
|
| 191 |
+
'radius': math.hypot(24, 12), 'type': 'car', 'mobile': True,
|
| 192 |
+
'color': random.choice(['#e74c3c', '#3498db', '#f1c40f', '#ecf0f1', '#2c3e50'])
|
| 193 |
+
})
|
| 194 |
+
self.dynamic_entities.append(car)
|
| 195 |
+
|
| 196 |
+
def add_player(self, session_id, user_info):
|
| 197 |
+
saved = self.persistent_data.get(session_id, {})
|
| 198 |
+
self.players[session_id] = {
|
| 199 |
+
'id': session_id,
|
| 200 |
+
'discord_id': user_info['id'],
|
| 201 |
+
'username': user_info['username'],
|
| 202 |
+
'avatar': f"https://cdn.discordapp.com/avatars/{user_info['id']}/{user_info['avatar']}.png" if user_info['avatar'] else "",
|
| 203 |
+
'x': random.random() * MAP_WIDTH,
|
| 204 |
+
'y': random.random() * MAP_HEIGHT,
|
| 205 |
+
'r': saved.get('r', 35),
|
| 206 |
+
'target_r': saved.get('r', 35),
|
| 207 |
+
'score': saved.get('score', 0),
|
| 208 |
+
'color': '#3498db',
|
| 209 |
+
'input_angle': 0, 'input_force': 0, 'vx': 0, 'vy': 0,
|
| 210 |
+
'alive': True, 'respawn_timer': 0
|
| 211 |
+
}
|
| 212 |
+
if session_id not in self.persistent_data:
|
| 213 |
+
self.persistent_data[session_id] = {'r': 35, 'score': 0, 'username': user_info['username']}
|
| 214 |
+
self.save_data()
|
| 215 |
+
|
| 216 |
+
def update(self, dt):
|
| 217 |
+
if not self.active: return
|
| 218 |
+
self.removed_entities = []
|
| 219 |
+
|
| 220 |
+
sorted_players = sorted(self.players.values(), key=lambda p: p['r'], reverse=True)
|
| 221 |
+
|
| 222 |
+
for p in sorted_players:
|
| 223 |
+
if not p['alive']:
|
| 224 |
+
p['respawn_timer'] -= dt
|
| 225 |
+
if p['respawn_timer'] <= 0:
|
| 226 |
+
p['alive'] = True
|
| 227 |
+
p['r'] = 35; p['target_r'] = 35; p['score'] = 0
|
| 228 |
+
p['x'] = random.random() * MAP_WIDTH
|
| 229 |
+
p['y'] = random.random() * MAP_HEIGHT
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
speed = 180 * (1 - (p['r'] / 800))
|
| 233 |
+
p['vx'] = math.cos(p['input_angle']) * p['input_force'] * speed
|
| 234 |
+
p['vy'] = math.sin(p['input_angle']) * p['input_force'] * speed
|
| 235 |
+
p['x'] = max(p['r'], min(MAP_WIDTH - p['r'], p['x'] + p['vx'] * dt))
|
| 236 |
+
p['y'] = max(p['r'], min(MAP_HEIGHT - p['r'], p['y'] + p['vy'] * dt))
|
| 237 |
+
|
| 238 |
+
if p['r'] < p['target_r']: p['r'] += (p['target_r'] - p['r']) * dt
|
| 239 |
+
|
| 240 |
+
# Eat Static (OPTIMIZED: Use Spatial Hash)
|
| 241 |
+
nearby_static = self.get_nearby_static(p['x'], p['y'])
|
| 242 |
+
for ent in nearby_static:
|
| 243 |
+
# Basic check before expensive sqrt
|
| 244 |
+
if abs(p['x'] - ent['x']) > p['r'] or abs(p['y'] - ent['y']) > p['r']: continue
|
| 245 |
+
|
| 246 |
+
dist = math.hypot(p['x'] - ent['x'], p['y'] - ent['y'])
|
| 247 |
+
if dist < p['r'] and p['r'] > ent['radius']:
|
| 248 |
+
self.static_entities.remove(ent)
|
| 249 |
+
# Also remove from grid for consistency (slow, but eats are rare compared to frames)
|
| 250 |
+
gx, gy = int(ent['x']//CELL_SIZE), int(ent['y']//CELL_SIZE)
|
| 251 |
+
if ent in self.static_grid[(gx, gy)]:
|
| 252 |
+
self.static_grid[(gx, gy)].remove(ent)
|
| 253 |
+
|
| 254 |
+
self.removed_entities.append(ent['id'])
|
| 255 |
+
p['score'] += ent['score']
|
| 256 |
+
p['target_r'] += math.sqrt(ent['score']) * 0.3
|
| 257 |
+
|
| 258 |
+
# Eat Cars
|
| 259 |
+
for car in self.dynamic_entities:
|
| 260 |
+
dist = math.hypot(p['x'] - car['x'], p['y'] - car['y'])
|
| 261 |
+
if dist < p['r'] and p['r'] > car['radius']:
|
| 262 |
+
p['score'] += car['score']
|
| 263 |
+
p['target_r'] += math.sqrt(car['score']) * 0.3
|
| 264 |
+
car['x'] = random.randint(0, GRID_SIZE) * CELL_SIZE
|
| 265 |
+
car['y'] = random.random() * MAP_HEIGHT
|
| 266 |
+
self.removed_entities.append(car['id'])
|
| 267 |
+
|
| 268 |
+
# Eat Players
|
| 269 |
+
for other in sorted_players:
|
| 270 |
+
if p == other or not other['alive']: continue
|
| 271 |
+
dist = math.hypot(p['x'] - other['x'], p['y'] - other['y'])
|
| 272 |
+
if p['r'] > other['r'] * 1.1 and dist < p['r'] - other['r']*0.4:
|
| 273 |
+
other['alive'] = False
|
| 274 |
+
other['respawn_timer'] = 5
|
| 275 |
+
p['score'] += 50 + other['score'] * 0.5
|
| 276 |
+
p['target_r'] += 5
|
| 277 |
+
self.persistent_data[p['id']]['score'] = p['score']
|
| 278 |
+
self.persistent_data[p['id']]['r'] = p['target_r']
|
| 279 |
+
self.persistent_data[other['id']]['score'] = 0
|
| 280 |
+
self.persistent_data[other['id']]['r'] = 35
|
| 281 |
+
self.save_data()
|
| 282 |
+
|
| 283 |
+
# Update Cars
|
| 284 |
+
for car in self.dynamic_entities:
|
| 285 |
+
speed = 150
|
| 286 |
+
car['x'] += math.cos(car['rotation']) * speed * dt
|
| 287 |
+
car['y'] += math.sin(car['rotation']) * speed * dt
|
| 288 |
+
if car['x'] > MAP_WIDTH: car['x'] = 0
|
| 289 |
+
if car['x'] < 0: car['x'] = MAP_WIDTH
|
| 290 |
+
if car['y'] > MAP_HEIGHT: car['y'] = 0
|
| 291 |
+
if car['y'] < 0: car['y'] = MAP_HEIGHT
|
| 292 |
+
|
| 293 |
+
game = GameState()
|
| 294 |
+
|
| 295 |
+
# --- WEB HANDLERS ---
|
| 296 |
+
async def handle_index(request): return web.FileResponse('./index.html')
|
| 297 |
+
async def handle_admin(request): return web.FileResponse('./admin.html')
|
| 298 |
+
async def handle_login(request):
|
| 299 |
+
return web.HTTPFound(f"https://discord.com/api/oauth2/authorize?client_id={DISCORD_CLIENT_ID}&redirect_uri={DISCORD_REDIRECT_URI}&response_type=code&scope=identify")
|
| 300 |
+
|
| 301 |
+
async def handle_callback(request):
|
| 302 |
+
code = request.query.get('code')
|
| 303 |
+
if not code: return web.Response(text="No code")
|
| 304 |
+
async with ClientSession() as s:
|
| 305 |
+
data = {'client_id': DISCORD_CLIENT_ID, 'client_secret': DISCORD_CLIENT_SECRET, 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': DISCORD_REDIRECT_URI}
|
| 306 |
+
async with s.post('https://discord.com/api/oauth2/token', data=data) as r:
|
| 307 |
+
token_data = await r.json()
|
| 308 |
+
if 'access_token' not in token_data: return web.Response(text="Auth failed")
|
| 309 |
+
headers = {'Authorization': f"Bearer {token_data['access_token']}"}
|
| 310 |
+
async with s.get('https://discord.com/api/users/@me', headers=headers) as r:
|
| 311 |
+
user_data = await r.json()
|
| 312 |
+
sid = str(uuid.uuid4())
|
| 313 |
+
game.add_player(sid, user_data)
|
| 314 |
+
|
| 315 |
+
# Broadcast new player metadata to everyone
|
| 316 |
+
p = game.players[sid]
|
| 317 |
+
meta = {'id': p['id'], 'username': p['username'], 'avatar': p['avatar'], 'color': p['color']}
|
| 318 |
+
await sio.emit('player_joined', meta)
|
| 319 |
+
|
| 320 |
+
return web.HTTPFound(f"/?token={sid}")
|
| 321 |
+
|
| 322 |
+
app.router.add_get('/', handle_index)
|
| 323 |
+
app.router.add_get('/login', handle_login)
|
| 324 |
+
app.router.add_get('/callback', handle_callback)
|
| 325 |
+
app.router.add_get(ADMIN_PATH, handle_admin)
|
| 326 |
+
|
| 327 |
+
@sio.event
|
| 328 |
+
async def connect(sid, environ, auth):
|
| 329 |
+
token = auth.get('token') if auth else None
|
| 330 |
+
if not token or token not in game.players: return False
|
| 331 |
+
game.sockets[sid] = token
|
| 332 |
+
|
| 333 |
+
# Send Static Info (Map + All Player Metadata)
|
| 334 |
+
all_players_meta = [
|
| 335 |
+
{'id': p['id'], 'username': p['username'], 'avatar': p['avatar'], 'color': p['color']}
|
| 336 |
+
for p in game.players.values()
|
| 337 |
+
]
|
| 338 |
+
|
| 339 |
+
await sio.emit('init_game', {
|
| 340 |
+
'width': MAP_WIDTH, 'height': MAP_HEIGHT,
|
| 341 |
+
'static_entities': game.static_entities,
|
| 342 |
+
'players_meta': all_players_meta
|
| 343 |
+
}, to=sid)
|
| 344 |
+
|
| 345 |
+
@sio.event
|
| 346 |
+
async def disconnect(sid):
|
| 347 |
+
if sid in game.sockets:
|
| 348 |
+
pid = game.sockets[sid]
|
| 349 |
+
del game.sockets[sid]
|
| 350 |
+
await sio.emit('player_left', {'id': pid})
|
| 351 |
+
|
| 352 |
+
@sio.event
|
| 353 |
+
async def input_data(sid, data):
|
| 354 |
+
if sid in game.sockets:
|
| 355 |
+
p = game.players[game.sockets[sid]]
|
| 356 |
+
p['input_angle'] = data.get('angle', 0)
|
| 357 |
+
p['input_force'] = min(max(data.get('force', 0), 0), 1)
|
| 358 |
+
|
| 359 |
+
@sio.event
|
| 360 |
+
async def admin_cmd(sid, data):
|
| 361 |
+
cmd = data.get('cmd')
|
| 362 |
+
if cmd == 'start_tournament':
|
| 363 |
+
game.active = True
|
| 364 |
+
game.generate_map()
|
| 365 |
+
for p in game.players.values():
|
| 366 |
+
p['r'] = 35; p['target_r'] = 35; p['score'] = 0; p['alive'] = True
|
| 367 |
+
p['x'] = random.random() * MAP_WIDTH; p['y'] = random.random() * MAP_HEIGHT
|
| 368 |
+
await sio.emit('tournament_reset', {'static_entities': game.static_entities})
|
| 369 |
+
elif cmd == 'stop_tournament':
|
| 370 |
+
game.active = False
|
| 371 |
+
res = [{'username': p['username'], 'score': int(p['score']), 'avatar': p['avatar']} for p in game.players.values()]
|
| 372 |
+
await sio.emit('tournament_end', {'results': sorted(res, key=lambda x: x['score'], reverse=True)})
|
| 373 |
+
|
| 374 |
+
async def game_loop():
|
| 375 |
+
while True:
|
| 376 |
+
start = time.time()
|
| 377 |
+
if game.active:
|
| 378 |
+
game.update(1/GAME_TICK_RATE)
|
| 379 |
+
|
| 380 |
+
# OPTIMIZED PACKET: Strip static data (avatar, username, color, car dimensions)
|
| 381 |
+
# Only send dynamic data (id, x, y, r, score, alive)
|
| 382 |
+
state = {
|
| 383 |
+
'players': [
|
| 384 |
+
{'id': p['id'], 'x': int(p['x']), 'y': int(p['y']), 'r': int(p['r']),
|
| 385 |
+
'alive': p['alive'], 'score': int(p['score'])}
|
| 386 |
+
for p in game.players.values() if p['alive']
|
| 387 |
+
],
|
| 388 |
+
'cars': [
|
| 389 |
+
{'id': c['id'], 'x': int(c['x']), 'y': int(c['y']), 'rotation': round(c['rotation'], 2),
|
| 390 |
+
'color': c['color'], 'w': c['w'], 'l': c['l'], 'h': c['h'], # Keep car static for now as they respawn often
|
| 391 |
+
'cabinW': c.get('cabinW'), 'cabinL': c.get('cabinL'), 'type': 'car'}
|
| 392 |
+
for c in game.dynamic_entities
|
| 393 |
+
],
|
| 394 |
+
'removed': game.removed_entities
|
| 395 |
+
}
|
| 396 |
+
await sio.emit('game_update', state)
|
| 397 |
+
|
| 398 |
+
await asyncio.sleep(max(0, (1/GAME_TICK_RATE) - (time.time() - start)))
|
| 399 |
+
|
| 400 |
+
async def init_app():
|
| 401 |
+
asyncio.create_task(game_loop())
|
| 402 |
+
return app
|
| 403 |
+
|
| 404 |
+
if __name__ == '__main__':
|
| 405 |
+
web.run_app(init_app(), port=PORT)
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohttp==3.9.3
|
| 2 |
+
python-socketio==5.11.1
|