|
|
import json |
|
|
import asyncio |
|
|
import os |
|
|
import time |
|
|
import random |
|
|
from typing import Dict, List |
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect |
|
|
from fastapi.responses import HTMLResponse |
|
|
|
|
|
app = FastAPI() |
|
|
|
|
|
|
|
|
DATA_FILE = "/data/leaderboard.json" if os.path.exists("/data") else "leaderboard.json" |
|
|
|
|
|
LEVEL_SEED = int(time.time() / (24 * 3600)) |
|
|
|
|
|
class ConnectionManager: |
|
|
def __init__(self): |
|
|
self.active_connections: Dict[WebSocket, dict] = {} |
|
|
self.leaderboard: List[dict] = self.load_leaderboard() |
|
|
|
|
|
def load_leaderboard(self): |
|
|
if os.path.exists(DATA_FILE): |
|
|
try: |
|
|
with open(DATA_FILE, "r") as f: |
|
|
return json.load(f) |
|
|
except: |
|
|
return [] |
|
|
return [] |
|
|
|
|
|
def save_leaderboard(self): |
|
|
|
|
|
self.leaderboard.sort(key=lambda x: x['score'], reverse=True) |
|
|
self.leaderboard = self.leaderboard[:10] |
|
|
try: |
|
|
with open(DATA_FILE, "w") as f: |
|
|
json.dump(self.leaderboard, f) |
|
|
except Exception as e: |
|
|
print(f"Save Error: {e}") |
|
|
|
|
|
async def connect(self, websocket: WebSocket): |
|
|
await websocket.accept() |
|
|
|
|
|
skin_id = random.randint(0, 3) |
|
|
self.active_connections[websocket] = { |
|
|
"u": "Loading...", |
|
|
"x": 0, "y": 0, "vx": 0, "vy": 0, |
|
|
"s": 0, |
|
|
"f": 1, |
|
|
"sk": skin_id, |
|
|
"d": False |
|
|
} |
|
|
|
|
|
await websocket.send_json({ |
|
|
"t": "init", |
|
|
"seed": LEVEL_SEED, |
|
|
"lb": self.leaderboard, |
|
|
"skin": skin_id |
|
|
}) |
|
|
|
|
|
def disconnect(self, websocket: WebSocket): |
|
|
if websocket in self.active_connections: |
|
|
data = self.active_connections[websocket] |
|
|
self.update_leaderboard(data['u'], data['s']) |
|
|
del self.active_connections[websocket] |
|
|
|
|
|
def update_leaderboard(self, username, score): |
|
|
if score < 50 or username == "Guest": return |
|
|
|
|
|
|
|
|
for entry in self.leaderboard: |
|
|
if entry['username'] == username: |
|
|
if score > entry['score']: |
|
|
entry['score'] = score |
|
|
self.save_leaderboard() |
|
|
return |
|
|
|
|
|
|
|
|
self.leaderboard.append({"username": username, "score": score}) |
|
|
self.save_leaderboard() |
|
|
|
|
|
async def handle_message(self, websocket: WebSocket, data: dict): |
|
|
if websocket not in self.active_connections: return |
|
|
p = self.active_connections[websocket] |
|
|
|
|
|
if data['t'] == 'u': |
|
|
p['u'] = data.get('n', 'Guest')[:12] |
|
|
p['x'] = data.get('x', 0) |
|
|
p['y'] = data.get('y', 0) |
|
|
p['vx'] = data.get('vx', 0) |
|
|
p['vy'] = data.get('vy', 0) |
|
|
p['f'] = data.get('f', 1) |
|
|
p['s'] = data.get('s', 0) |
|
|
p['d'] = False |
|
|
|
|
|
elif data['t'] == 'd': |
|
|
p['d'] = True |
|
|
self.update_leaderboard(p['u'], data.get('s', 0)) |
|
|
|
|
|
async def broadcast(self): |
|
|
if not self.active_connections: return |
|
|
|
|
|
|
|
|
players_packed = [] |
|
|
for ws, p in self.active_connections.items(): |
|
|
if not p['d']: |
|
|
players_packed.append({ |
|
|
"i": id(ws), |
|
|
"n": p['u'], |
|
|
"x": int(p['x']), |
|
|
"y": int(p['y']), |
|
|
"vx": round(p['vx'], 1), |
|
|
"vy": round(p['vy'], 1), |
|
|
"f": p['f'], |
|
|
"s": p['s'], |
|
|
"sk": p['sk'] |
|
|
}) |
|
|
|
|
|
msg = { "t": "w", "p": players_packed, "lb": self.leaderboard } |
|
|
|
|
|
disconnected = [] |
|
|
for ws in self.active_connections: |
|
|
try: |
|
|
await ws.send_json(msg) |
|
|
except: |
|
|
disconnected.append(ws) |
|
|
|
|
|
for ws in disconnected: |
|
|
self.disconnect(ws) |
|
|
|
|
|
manager = ConnectionManager() |
|
|
|
|
|
@app.on_event("startup") |
|
|
async def start_loop(): |
|
|
asyncio.create_task(broadcast_task()) |
|
|
|
|
|
async def broadcast_task(): |
|
|
|
|
|
|
|
|
while True: |
|
|
start = time.time() |
|
|
await manager.broadcast() |
|
|
elapsed = time.time() - start |
|
|
await asyncio.sleep(max(0, 0.033 - elapsed)) |
|
|
|
|
|
@app.websocket("/ws") |
|
|
async def websocket_endpoint(websocket: WebSocket): |
|
|
await manager.connect(websocket) |
|
|
try: |
|
|
while True: |
|
|
data = await websocket.receive_json() |
|
|
await manager.handle_message(websocket, data) |
|
|
except WebSocketDisconnect: |
|
|
manager.disconnect(websocket) |
|
|
except: |
|
|
manager.disconnect(websocket) |
|
|
|
|
|
@app.get("/", response_class=HTMLResponse) |
|
|
async def get(): |
|
|
return """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
|
|
<title>Sky Jump Pro</title> |
|
|
<style> |
|
|
@import url('https://fonts.googleapis.com/css2?family=Fredoka+One&display=swap'); |
|
|
|
|
|
body { |
|
|
margin: 0; |
|
|
background-color: #2c3e50; |
|
|
display: flex; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
height: 100vh; |
|
|
overflow: hidden; |
|
|
font-family: 'Fredoka One', cursive; |
|
|
user-select: none; |
|
|
-webkit-user-select: none; |
|
|
touch-action: none; |
|
|
} |
|
|
|
|
|
/* THE GAME CONTAINER - FIXED ASPECT RATIO */ |
|
|
#game-container { |
|
|
position: relative; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
max-width: 540px; /* Force mobile width on desktop */ |
|
|
max-height: 960px; |
|
|
background: #fff; |
|
|
box-shadow: 0 0 50px rgba(0,0,0,0.5); |
|
|
} |
|
|
|
|
|
canvas { |
|
|
display: block; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
} |
|
|
|
|
|
/* UI OVERLAY */ |
|
|
#ui-layer { |
|
|
position: absolute; |
|
|
top: 0; left: 0; width: 100%; height: 100%; |
|
|
pointer-events: none; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
/* SCALING SCORE */ |
|
|
.hud-top { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
padding: 20px; |
|
|
font-size: clamp(20px, 5vh, 40px); /* Responsive Font */ |
|
|
color: #333; |
|
|
text-shadow: 2px 2px 0px rgba(255,255,255,0.5); |
|
|
} |
|
|
|
|
|
#score-display { color: #333; } |
|
|
#ping-display { font-size: 0.5em; color: #888; margin-top: 10px; } |
|
|
|
|
|
/* SCREENS */ |
|
|
.screen { |
|
|
position: absolute; |
|
|
top: 0; left: 0; width: 100%; height: 100%; |
|
|
background: rgba(255, 255, 255, 0.95); |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
justify-content: center; |
|
|
align-items: center; |
|
|
pointer-events: auto; |
|
|
transition: opacity 0.3s; |
|
|
z-index: 10; |
|
|
text-align: center; |
|
|
} |
|
|
.hidden { opacity: 0; pointer-events: none; } |
|
|
|
|
|
h1 { |
|
|
font-size: clamp(40px, 8vh, 60px); |
|
|
margin: 0; |
|
|
color: #e67e22; |
|
|
text-shadow: 3px 3px #2c3e50; |
|
|
letter-spacing: 2px; |
|
|
} |
|
|
|
|
|
p.subtitle { color: #7f8c8d; margin-top: 5px; margin-bottom: 40px; } |
|
|
|
|
|
input { |
|
|
padding: 15px; |
|
|
border-radius: 12px; |
|
|
border: 3px solid #bdc3c7; |
|
|
font-size: 20px; |
|
|
font-family: inherit; |
|
|
text-align: center; |
|
|
outline: none; |
|
|
width: 60%; |
|
|
transition: border 0.2s; |
|
|
} |
|
|
input:focus { border-color: #e67e22; } |
|
|
|
|
|
button { |
|
|
margin-top: 20px; |
|
|
padding: 15px 50px; |
|
|
font-size: 24px; |
|
|
border: none; |
|
|
background: #e67e22; |
|
|
color: white; |
|
|
border-radius: 50px; |
|
|
font-family: inherit; |
|
|
cursor: pointer; |
|
|
box-shadow: 0 5px 0 #d35400; |
|
|
transition: transform 0.1s, box-shadow 0.1s; |
|
|
} |
|
|
button:active { transform: translateY(5px); box-shadow: 0 0 0 #d35400; } |
|
|
|
|
|
/* LEADERBOARD */ |
|
|
#leaderboard-box { |
|
|
position: absolute; |
|
|
bottom: 20px; |
|
|
left: 50%; |
|
|
transform: translateX(-50%); |
|
|
width: 80%; |
|
|
background: #ecf0f1; |
|
|
border-radius: 15px; |
|
|
padding: 15px; |
|
|
text-align: left; |
|
|
} |
|
|
#lb-list { font-size: 0.8em; color: #2c3e50; line-height: 1.6; } |
|
|
.lb-row { display: flex; justify-content: space-between; border-bottom: 1px solid #bdc3c7; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
|
|
|
<div id="game-container"> |
|
|
<canvas id="gameCanvas"></canvas> |
|
|
|
|
|
<!-- UI Layer --> |
|
|
<div id="ui-layer"> |
|
|
<div class="hud-top"> |
|
|
<span id="score-display">0</span> |
|
|
<span id="ping-display">Offline</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Login Screen --> |
|
|
<div id="login-screen" class="screen"> |
|
|
<h1>SKY JUMP</h1> |
|
|
<p class="subtitle">Multiplayer Edition</p> |
|
|
<input type="text" id="username-input" placeholder="Nickname" maxlength="10"> |
|
|
<button onclick="startGame()">PLAY</button> |
|
|
<div id="leaderboard-box"> |
|
|
<div style="text-align:center; color:#e67e22; margin-bottom:5px;">TOP PLAYERS</div> |
|
|
<div id="lb-list">Loading...</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Game Over Screen --> |
|
|
<div id="gameover-screen" class="screen hidden"> |
|
|
<h1 style="color:#e74c3c">FALLEN</h1> |
|
|
<p class="subtitle" id="final-score">Score: 0</p> |
|
|
<button onclick="resetGame()">AGAIN</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
/** |
|
|
* HIGH PERFORMANCE GAME ENGINE |
|
|
*/ |
|
|
|
|
|
// --- CONFIG --- |
|
|
const LOGICAL_WIDTH = 540; |
|
|
const LOGICAL_HEIGHT = 960; |
|
|
const GRAVITY = 0.35; |
|
|
const JUMP_FORCE = -14.5; |
|
|
const SPEED = 8; |
|
|
const SKINS = ['#e67e22', '#3498db', '#9b59b6', '#2ecc71']; |
|
|
|
|
|
// --- STATE --- |
|
|
const canvas = document.getElementById('gameCanvas'); |
|
|
const ctx = canvas.getContext('2d', { alpha: false }); // Optimize |
|
|
let gameState = 'MENU'; // MENU, PLAY, DEAD |
|
|
let socket; |
|
|
let lastPingTime = 0; |
|
|
|
|
|
// World |
|
|
let platforms = []; |
|
|
let particles = []; |
|
|
let otherPlayers = {}; // Map of ID -> Data |
|
|
let cameraY = 0; |
|
|
let targetCameraY = 0; |
|
|
let score = 0; |
|
|
let worldSeed = 1; |
|
|
|
|
|
// Player |
|
|
let player = { |
|
|
x: LOGICAL_WIDTH/2, |
|
|
y: 0, |
|
|
vx: 0, |
|
|
vy: 0, |
|
|
w: 50, h: 50, |
|
|
face: 1, |
|
|
skin: 0 |
|
|
}; |
|
|
let input = { left: false, right: false }; |
|
|
|
|
|
// --- NETWORK --- |
|
|
function connect() { |
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
|
|
socket = new WebSocket(`${proto}//${location.host}/ws`); |
|
|
|
|
|
socket.onopen = () => { |
|
|
document.getElementById('ping-display').innerText = "Connected"; |
|
|
// Auto-login check |
|
|
const savedName = localStorage.getItem('skyjump_user'); |
|
|
if(savedName && gameState === 'MENU') { |
|
|
document.getElementById('username-input').value = savedName; |
|
|
// Uncomment next line to Auto-Start |
|
|
// startGame(); |
|
|
} |
|
|
}; |
|
|
|
|
|
socket.onmessage = (e) => { |
|
|
const msg = JSON.parse(e.data); |
|
|
if (msg.t === 'init') { |
|
|
worldSeed = msg.seed; |
|
|
player.skin = msg.skin; |
|
|
renderLeaderboard(msg.lb); |
|
|
} |
|
|
else if (msg.t === 'w') { // World Update |
|
|
handleWorldUpdate(msg); |
|
|
} |
|
|
}; |
|
|
|
|
|
socket.onclose = () => { |
|
|
document.getElementById('ping-display').innerText = "Reconnecting..."; |
|
|
setTimeout(connect, 1000); |
|
|
}; |
|
|
} |
|
|
|
|
|
function handleWorldUpdate(msg) { |
|
|
if(msg.lb) renderLeaderboard(msg.lb); |
|
|
|
|
|
// Process Players |
|
|
const now = performance.now(); |
|
|
msg.p.forEach(p => { |
|
|
// Interpolation logic |
|
|
let op = otherPlayers[p.i]; |
|
|
if(!op) { |
|
|
op = { x: p.x, y: p.y, vx: 0, vy: 0, currX: p.x, currY: p.y, targetX: p.x, targetY: p.y, lastUpdate: now }; |
|
|
otherPlayers[p.i] = op; |
|
|
} |
|
|
|
|
|
op.targetX = p.x; |
|
|
op.targetY = p.y; |
|
|
op.face = p.f; |
|
|
op.u = p.n; |
|
|
op.skin = p.sk; |
|
|
op.lastUpdate = now; |
|
|
}); |
|
|
|
|
|
// Cleanup stale players |
|
|
for(let id in otherPlayers) { |
|
|
if(now - otherPlayers[id].lastUpdate > 3000) delete otherPlayers[id]; |
|
|
} |
|
|
} |
|
|
|
|
|
function sendUpdate() { |
|
|
if(gameState !== 'PLAY' || !socket || socket.readyState !== 1) return; |
|
|
socket.send(JSON.stringify({ |
|
|
t: 'u', |
|
|
n: document.getElementById('username-input').value, |
|
|
x: Math.round(player.x), |
|
|
y: Math.round(player.y), |
|
|
vx: parseFloat(player.vx.toFixed(2)), |
|
|
vy: parseFloat(player.vy.toFixed(2)), |
|
|
f: player.face, |
|
|
s: Math.floor(score) |
|
|
})); |
|
|
} |
|
|
|
|
|
// --- ENGINE --- |
|
|
|
|
|
function resize() { |
|
|
// Scale canvas to fit window while maintaining aspect ratio |
|
|
// We render to internal high-res, CSS scales it down |
|
|
canvas.width = LOGICAL_WIDTH; |
|
|
canvas.height = LOGICAL_HEIGHT; |
|
|
} |
|
|
window.addEventListener('resize', resize); |
|
|
resize(); |
|
|
|
|
|
// RNG |
|
|
function randomAt(y) { |
|
|
// Deterministic random based on Y coordinate + Daily Seed |
|
|
let x = Math.sin(y * 12.9898 + worldSeed) * 43758.5453; |
|
|
return x - Math.floor(x); |
|
|
} |
|
|
|
|
|
function generateLevel() { |
|
|
// Create visible platforms based on cameraY |
|
|
// We generate "virtual" platforms. |
|
|
// Chunk generation: |
|
|
const topY = cameraY + LOGICAL_HEIGHT * 1.5; |
|
|
|
|
|
// We ensure platforms exist every ~100 units |
|
|
// Start finding platforms from the highest one we have, or from ground |
|
|
let startY = platforms.length > 0 ? platforms[platforms.length-1].y + 100 : 200; |
|
|
|
|
|
while(startY < topY) { |
|
|
// Platform probability |
|
|
if (randomAt(startY) > 0.1) { |
|
|
let px = randomAt(startY + 1) * (LOGICAL_WIDTH - 80); |
|
|
let pType = 0; // 0=Static, 1=Moving |
|
|
|
|
|
if (startY > 2000 && randomAt(startY+2) > 0.7) pType = 1; |
|
|
|
|
|
platforms.push({ |
|
|
x: px, |
|
|
y: startY, |
|
|
w: 80, |
|
|
h: 20, |
|
|
type: pType, |
|
|
vx: pType ? (randomAt(startY+3) > 0.5 ? 2 : -2) : 0, |
|
|
offset: randomAt(startY+4) * Math.PI * 2 // For sine movement |
|
|
}); |
|
|
} |
|
|
startY += 80 + (randomAt(startY+5) * 60); // Random gap 80-140 |
|
|
} |
|
|
|
|
|
// Cleanup |
|
|
platforms = platforms.filter(p => p.y > cameraY - 100); |
|
|
} |
|
|
|
|
|
function startGame() { |
|
|
const name = document.getElementById('username-input').value.trim(); |
|
|
if(!name) return alert("Please enter a name"); |
|
|
localStorage.setItem('skyjump_user', name); |
|
|
|
|
|
document.getElementById('login-screen').classList.add('hidden'); |
|
|
document.getElementById('gameover-screen').classList.add('hidden'); |
|
|
|
|
|
// Reset |
|
|
player.x = LOGICAL_WIDTH/2; |
|
|
player.y = 100; |
|
|
player.vx = 0; |
|
|
player.vy = 0; |
|
|
cameraY = 0; |
|
|
targetCameraY = 0; |
|
|
score = 0; |
|
|
platforms = [{x: LOGICAL_WIDTH/2 - 50, y: 0, w: 100, h: 20, type: 0, vx:0}]; // Start platform |
|
|
|
|
|
gameState = 'PLAY'; |
|
|
loop(); |
|
|
} |
|
|
|
|
|
function resetGame() { |
|
|
startGame(); |
|
|
} |
|
|
|
|
|
function spawnParticles(x, y, color) { |
|
|
for(let i=0; i<8; i++) { |
|
|
particles.push({ |
|
|
x: x, y: y, |
|
|
vx: (Math.random() - 0.5) * 6, |
|
|
vy: (Math.random() - 0.5) * 6, |
|
|
life: 1.0, |
|
|
color: color |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
function updatePhysics() { |
|
|
// Horizontal |
|
|
if (input.left) player.vx = -SPEED; |
|
|
else if (input.right) player.vx = SPEED; |
|
|
else player.vx *= 0.85; // Friction |
|
|
|
|
|
player.x += player.vx; |
|
|
if(player.vx > 0.5) player.face = 1; |
|
|
if(player.vx < -0.5) player.face = -1; |
|
|
|
|
|
// Wrap |
|
|
if (player.x < -player.w/2) player.x = LOGICAL_WIDTH - player.w/2; |
|
|
if (player.x > LOGICAL_WIDTH - player.w/2) player.x = -player.w/2; |
|
|
|
|
|
// Vertical |
|
|
player.vy -= GRAVITY; |
|
|
player.y += player.vy; |
|
|
|
|
|
// Collision (Only when falling) |
|
|
if (player.vy < 0) { |
|
|
for (let p of platforms) { |
|
|
// AABB with tolerance |
|
|
if (player.x + player.w*0.3 > p.x && player.x + player.w*0.7 < p.x + p.w && |
|
|
player.y >= p.y && player.y + player.vy <= p.y + 15) { |
|
|
|
|
|
player.vy = -JUMP_FORCE; // Jump |
|
|
player.y = p.y; // Snap |
|
|
spawnParticles(player.x + player.w/2, player.y, '#ecf0f1'); |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Platform Logic |
|
|
platforms.forEach(p => { |
|
|
if(p.type === 1) { |
|
|
// Sine wave movement for smoothness |
|
|
p.x += Math.sin(Date.now() / 500 + p.offset) * 2; |
|
|
} |
|
|
}); |
|
|
|
|
|
// Camera Logic (Smooth Lerp) |
|
|
if (player.y > targetCameraY + LOGICAL_HEIGHT * 0.3) { |
|
|
targetCameraY = player.y - LOGICAL_HEIGHT * 0.3; |
|
|
} |
|
|
// Only move camera up |
|
|
if (targetCameraY > cameraY) { |
|
|
cameraY += (targetCameraY - cameraY) * 0.1; |
|
|
} |
|
|
|
|
|
// Score |
|
|
if (cameraY > score) score = cameraY; |
|
|
document.getElementById('score-display').innerText = Math.floor(score); |
|
|
|
|
|
// Death |
|
|
if (player.y < cameraY - 100) { |
|
|
gameState = 'DEAD'; |
|
|
socket.send(JSON.stringify({t:'d', s:Math.floor(score)})); |
|
|
document.getElementById('final-score').innerText = "Score: " + Math.floor(score); |
|
|
document.getElementById('gameover-screen').classList.remove('hidden'); |
|
|
} |
|
|
|
|
|
generateLevel(); |
|
|
} |
|
|
|
|
|
// --- DRAWING --- |
|
|
|
|
|
function draw() { |
|
|
// 1. Background |
|
|
// Draw a Graph Paper pattern |
|
|
ctx.fillStyle = '#f7f9fa'; |
|
|
ctx.fillRect(0,0, LOGICAL_WIDTH, LOGICAL_HEIGHT); |
|
|
|
|
|
ctx.strokeStyle = '#e1e8ed'; |
|
|
ctx.lineWidth = 2; |
|
|
ctx.beginPath(); |
|
|
|
|
|
// Parallax grid effect |
|
|
let gridY = Math.floor(cameraY * 0.5) % 40; |
|
|
for(let i=0; i<LOGICAL_WIDTH; i+=40) { ctx.moveTo(i,0); ctx.lineTo(i, LOGICAL_HEIGHT); } |
|
|
for(let i=0; i<LOGICAL_HEIGHT; i+=40) { |
|
|
let y = LOGICAL_HEIGHT - (i - gridY); |
|
|
ctx.moveTo(0, y); ctx.lineTo(LOGICAL_WIDTH, y); |
|
|
} |
|
|
ctx.stroke(); |
|
|
|
|
|
// 2. Platforms |
|
|
platforms.forEach(p => { |
|
|
let py = LOGICAL_HEIGHT - (p.y - cameraY); |
|
|
if(py < -50 || py > LOGICAL_HEIGHT+50) return; |
|
|
|
|
|
// Shadow |
|
|
ctx.fillStyle = 'rgba(0,0,0,0.1)'; |
|
|
ctx.beginPath(); ctx.roundRect(p.x+5, py+5, p.w, p.h, 10); ctx.fill(); |
|
|
|
|
|
// Body |
|
|
ctx.fillStyle = p.type === 1 ? '#3498db' : '#2ecc71'; |
|
|
ctx.beginPath(); ctx.roundRect(p.x, py, p.w, p.h, 10); ctx.fill(); |
|
|
|
|
|
// Highlight |
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)'; |
|
|
ctx.beginPath(); ctx.roundRect(p.x, py, p.w, p.h/2, 10); ctx.fill(); |
|
|
}); |
|
|
|
|
|
// 3. Other Players |
|
|
const now = performance.now(); |
|
|
for(let id in otherPlayers) { |
|
|
let op = otherPlayers[id]; |
|
|
|
|
|
// Interpolate |
|
|
let t = 0.2; |
|
|
op.currX += (op.targetX - op.currX) * t; |
|
|
op.currY += (op.targetY - op.currY) * t; |
|
|
|
|
|
let py = LOGICAL_HEIGHT - (op.currY - cameraY); |
|
|
|
|
|
// Only draw if visible |
|
|
if(py > -100 && py < LOGICAL_HEIGHT + 100) { |
|
|
drawCharacter(op.currX, py, op.face, op.skin, op.u, false); |
|
|
} |
|
|
} |
|
|
|
|
|
// 4. Local Player |
|
|
if(gameState === 'PLAY') { |
|
|
let py = LOGICAL_HEIGHT - (player.y - cameraY); |
|
|
drawCharacter(player.x, py, player.face, player.skin, null, true); |
|
|
} |
|
|
|
|
|
// 5. Particles |
|
|
particles.forEach((p, idx) => { |
|
|
p.life -= 0.05; |
|
|
p.x += p.vx; |
|
|
p.y += p.vy; |
|
|
let py = LOGICAL_HEIGHT - (p.y - cameraY); |
|
|
|
|
|
ctx.fillStyle = p.color; |
|
|
ctx.globalAlpha = p.life; |
|
|
ctx.beginPath(); ctx.arc(p.x, py, 5 * p.life, 0, Math.PI*2); ctx.fill(); |
|
|
ctx.globalAlpha = 1; |
|
|
|
|
|
if(p.life <= 0) particles.splice(idx, 1); |
|
|
}); |
|
|
} |
|
|
|
|
|
function drawCharacter(x, y, face, skinId, name, isSelf) { |
|
|
ctx.save(); |
|
|
ctx.translate(x + 25, y - 25); // Center |
|
|
if(face === -1) ctx.scale(-1, 1); |
|
|
|
|
|
// Color |
|
|
let color = SKINS[skinId] || SKINS[0]; |
|
|
|
|
|
// Legs (Simple animation) |
|
|
let legOffset = Math.sin(Date.now() / 50) * 3; |
|
|
if (Math.abs(isSelf ? player.vx : 0) < 0.1) legOffset = 0; |
|
|
|
|
|
ctx.strokeStyle = '#2c3e50'; |
|
|
ctx.lineWidth = 4; |
|
|
ctx.lineCap = 'round'; |
|
|
ctx.beginPath(); |
|
|
ctx.moveTo(-10, 15); ctx.lineTo(-15, 25 + legOffset); // Left Leg |
|
|
ctx.moveTo(10, 15); ctx.lineTo(15, 25 - legOffset); // Right Leg |
|
|
ctx.stroke(); |
|
|
|
|
|
// Body |
|
|
ctx.fillStyle = color; |
|
|
ctx.beginPath(); |
|
|
ctx.ellipse(0, 0, 20, 20, 0, 0, Math.PI*2); |
|
|
ctx.fill(); |
|
|
ctx.stroke(); |
|
|
|
|
|
// Eyes |
|
|
ctx.fillStyle = 'white'; |
|
|
ctx.beginPath(); ctx.arc(6, -5, 6, 0, Math.PI*2); ctx.fill(); ctx.stroke(); |
|
|
ctx.beginPath(); ctx.arc(14, -5, 4, 0, Math.PI*2); ctx.fill(); ctx.stroke(); // 2nd eye small |
|
|
|
|
|
ctx.fillStyle = 'black'; |
|
|
ctx.beginPath(); ctx.arc(8, -5, 2, 0, Math.PI*2); ctx.fill(); |
|
|
ctx.beginPath(); ctx.arc(15, -5, 1.5, 0, Math.PI*2); ctx.fill(); |
|
|
|
|
|
ctx.restore(); |
|
|
|
|
|
// Name tag |
|
|
if(name) { |
|
|
ctx.save(); |
|
|
ctx.fillStyle = "#7f8c8d"; |
|
|
ctx.font = "bold 14px sans-serif"; |
|
|
ctx.textAlign = "center"; |
|
|
let textY = y - 55; |
|
|
ctx.fillText(name, x + 25, textY); |
|
|
ctx.restore(); |
|
|
} |
|
|
} |
|
|
|
|
|
// --- LOOP --- |
|
|
let lastTime = 0; |
|
|
function loop(timestamp) { |
|
|
if(gameState === 'PLAY') { |
|
|
updatePhysics(); |
|
|
// Network Update (10 FPS limit for upload to save bandwidth, interpolate on receive) |
|
|
if(timestamp - lastTime > 100) { |
|
|
sendUpdate(); |
|
|
lastTime = timestamp; |
|
|
} |
|
|
} |
|
|
draw(); |
|
|
if(gameState === 'PLAY') requestAnimationFrame(loop); |
|
|
} |
|
|
|
|
|
// --- UI HELPERS --- |
|
|
function renderLeaderboard(lb) { |
|
|
const list = document.getElementById('lb-list'); |
|
|
list.innerHTML = lb.map((entry, i) => ` |
|
|
<div class="lb-row"> |
|
|
<span>#${i+1} ${entry.username}</span> |
|
|
<span style="font-weight:bold">${entry.score}</span> |
|
|
</div> |
|
|
`).join(''); |
|
|
} |
|
|
|
|
|
// --- CONTROLS --- |
|
|
window.addEventListener('keydown', e => { |
|
|
if(e.code === 'ArrowLeft') input.left = true; |
|
|
if(e.code === 'ArrowRight') input.right = true; |
|
|
}); |
|
|
window.addEventListener('keyup', e => { |
|
|
if(e.code === 'ArrowLeft') input.left = false; |
|
|
if(e.code === 'ArrowRight') input.right = false; |
|
|
}); |
|
|
|
|
|
// Touch |
|
|
const container = document.getElementById('game-container'); |
|
|
container.addEventListener('touchstart', e => { |
|
|
e.preventDefault(); |
|
|
const rect = canvas.getBoundingClientRect(); |
|
|
const touchX = e.touches[0].clientX - rect.left; |
|
|
const scaleX = LOGICAL_WIDTH / rect.width; |
|
|
const gameX = touchX * scaleX; |
|
|
|
|
|
if(gameX < LOGICAL_WIDTH/2) { input.left = true; input.right = false; } |
|
|
else { input.right = true; input.left = false; } |
|
|
}, {passive:false}); |
|
|
|
|
|
container.addEventListener('touchend', e => { |
|
|
e.preventDefault(); |
|
|
input.left = false; input.right = false; |
|
|
}); |
|
|
|
|
|
// Start |
|
|
connect(); |
|
|
|
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
uvicorn.run(app, host="0.0.0.0", port=7860) |