yt / app.py
OrbitMC's picture
Update app.py
2f84d2c verified
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()
# --- CONFIGURATION ---
DATA_FILE = "/data/leaderboard.json" if os.path.exists("/data") else "leaderboard.json"
# A fixed daily seed ensures everyone sees the same level layout
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):
# Sort by score desc, keep top 10
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()
# Assign a random skin/color to the player
skin_id = random.randint(0, 3)
self.active_connections[websocket] = {
"u": "Loading...",
"x": 0, "y": 0, "vx": 0, "vy": 0,
"s": 0, # score
"f": 1, # faceRight (1 or -1)
"sk": skin_id,
"d": False # dead
}
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
# Check existing
for entry in self.leaderboard:
if entry['username'] == username:
if score > entry['score']:
entry['score'] = score
self.save_leaderboard()
return
# Add new
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': # Update
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': # Death
p['d'] = True
self.update_leaderboard(p['u'], data.get('s', 0))
async def broadcast(self):
if not self.active_connections: return
# Compress data for bandwidth (1 char keys)
players_packed = []
for ws, p in self.active_connections.items():
if not p['d']: # Only send living players
players_packed.append({
"i": id(ws), # Unique temporary ID for interpolation
"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 } # w = world update
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():
# 30 Server Ticks per second is enough for smooth interpolation
# Sending 60fps packets over WebSocket often causes congestion/lag
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)