|
|
const express = require('express'); |
|
|
const http = require('http'); |
|
|
const { Server } = require('socket.io'); |
|
|
const path = require('path'); |
|
|
const zlib = require('zlib'); |
|
|
|
|
|
const app = express(); |
|
|
const server = http.createServer(app); |
|
|
|
|
|
|
|
|
const io = new Server(server, { |
|
|
cors: { |
|
|
origin: "*", |
|
|
methods: ["GET", "POST"] |
|
|
}, |
|
|
|
|
|
pingTimeout: 120000, |
|
|
pingInterval: 10000, |
|
|
upgradeTimeout: 30000, |
|
|
transports: ['websocket', 'polling'], |
|
|
allowUpgrades: true, |
|
|
perMessageDeflate: { |
|
|
threshold: 256, |
|
|
zlibDeflateOptions: { level: 6 }, |
|
|
zlibInflateOptions: { chunkSize: 10 * 1024 } |
|
|
}, |
|
|
httpCompression: true, |
|
|
maxHttpBufferSize: 1e6 |
|
|
}); |
|
|
|
|
|
app.use(express.static(path.join(__dirname, 'public'))); |
|
|
|
|
|
|
|
|
const TILE_SIZE = 10; |
|
|
const GRID_WIDTH = 13; |
|
|
const BASE_TICK_RATE = 15; |
|
|
const HOP_DURATION = 120; |
|
|
|
|
|
|
|
|
const players = new Map(); |
|
|
const gameState = { |
|
|
lanes: new Map(), |
|
|
furthestLaneIndex: 14, |
|
|
version: 0 |
|
|
}; |
|
|
|
|
|
|
|
|
const connectionStats = new Map(); |
|
|
|
|
|
|
|
|
function generateLaneData(index, prevLane) { |
|
|
let type = 'grass'; |
|
|
let runLength = 1; |
|
|
|
|
|
if (index <= 4) { |
|
|
type = 'grass'; |
|
|
} else if (prevLane) { |
|
|
const r = Math.random(); |
|
|
|
|
|
if (prevLane.type === 'grass') { |
|
|
if (r < 0.6) type = 'road'; |
|
|
else if (r < 0.9) type = 'water'; |
|
|
else type = 'grass'; |
|
|
} else if (prevLane.type === 'road') { |
|
|
if (prevLane.runLength < 4 && r < 0.7) { |
|
|
type = 'road'; |
|
|
runLength = prevLane.runLength + 1; |
|
|
} else { |
|
|
type = 'grass'; |
|
|
} |
|
|
} else if (prevLane.type === 'water') { |
|
|
if (prevLane.runLength < 2 && r < 0.6) { |
|
|
type = 'water'; |
|
|
runLength = prevLane.runLength + 1; |
|
|
} else { |
|
|
type = 'grass'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const staticObstacles = []; |
|
|
if (type === 'grass') { |
|
|
const treeCount = Math.floor(Math.random() * 3); |
|
|
for (let i = 0; i < treeCount; i++) { |
|
|
let gx = Math.floor(Math.random() * GRID_WIDTH) - Math.floor(GRID_WIDTH / 2); |
|
|
if (index < 5 && gx === 0) continue; |
|
|
if (!staticObstacles.includes(gx)) { |
|
|
staticObstacles.push(gx); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
const lane = { |
|
|
i: index, |
|
|
t: type.charAt(0), |
|
|
rl: runLength, |
|
|
so: staticObstacles, |
|
|
sp: Math.round((Math.random() * 0.04 + 0.02) * TILE_SIZE * 100) / 100, |
|
|
d: Math.random() > 0.5 ? 1 : -1, |
|
|
obs: [], |
|
|
timer: 0, |
|
|
interval: Math.random() * 100 + 150, |
|
|
obsId: 0 |
|
|
}; |
|
|
|
|
|
|
|
|
if (type === 'road' || type === 'water') { |
|
|
const count = Math.ceil(Math.random() * 2); |
|
|
for (let i = 0; i < count; i++) { |
|
|
let randX = (Math.random() * 120) - 60; |
|
|
if (Math.abs(randX) < 15) randX += (randX > 0 ? 20 : -20); |
|
|
|
|
|
const obsWidth = type === 'water' |
|
|
? (Math.random() > 0.5 ? 40 : 60) |
|
|
: 12; |
|
|
|
|
|
lane.obs.push({ |
|
|
id: lane.obsId++, |
|
|
x: Math.round(randX * 10) / 10, |
|
|
l: type === 'water' ? 1 : 0, |
|
|
w: obsWidth |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
return lane; |
|
|
} |
|
|
|
|
|
function initializeLanes() { |
|
|
gameState.lanes.clear(); |
|
|
for (let i = -4; i < 15; i++) { |
|
|
const prevLane = gameState.lanes.get(i - 1); |
|
|
gameState.lanes.set(i, generateLaneData(i, prevLane)); |
|
|
} |
|
|
gameState.furthestLaneIndex = 14; |
|
|
} |
|
|
|
|
|
initializeLanes(); |
|
|
|
|
|
|
|
|
function checkCollisionAtPosition(gridX, gridZ, worldX = null) { |
|
|
const lane = gameState.lanes.get(gridZ); |
|
|
if (!lane) return { hit: false }; |
|
|
|
|
|
const px = worldX !== null ? worldX : gridX * TILE_SIZE; |
|
|
|
|
|
if (Math.abs(px) > (GRID_WIDTH * TILE_SIZE / 2 + 10)) { |
|
|
return { hit: true, type: 'bounds' }; |
|
|
} |
|
|
|
|
|
if (lane.t === 'g') { |
|
|
if (lane.so.includes(gridX)) { |
|
|
return { hit: true, type: 'tree', blocked: true }; |
|
|
} |
|
|
return { hit: false }; |
|
|
} |
|
|
|
|
|
if (lane.t === 'r') { |
|
|
for (const obs of lane.obs) { |
|
|
const left = obs.x - obs.w / 2; |
|
|
const right = obs.x + obs.w / 2; |
|
|
if (px + 3 > left && px - 3 < right) { |
|
|
return { hit: true, type: 'car' }; |
|
|
} |
|
|
} |
|
|
return { hit: false }; |
|
|
} |
|
|
|
|
|
if (lane.t === 'w') { |
|
|
for (const obs of lane.obs) { |
|
|
if (Math.abs(px - obs.x) < (obs.w / 2) + 4) { |
|
|
return { hit: false, onLog: true, logId: obs.id }; |
|
|
} |
|
|
} |
|
|
return { hit: true, type: 'water' }; |
|
|
} |
|
|
|
|
|
return { hit: false }; |
|
|
} |
|
|
|
|
|
function validateMove(player, dx, dz) { |
|
|
if (!player.alive) return { valid: false, reason: 'dead' }; |
|
|
if (dz < 0) return { valid: false, reason: 'backward' }; |
|
|
|
|
|
const targetX = player.gx + dx; |
|
|
const targetZ = player.gz + dz; |
|
|
|
|
|
if (Math.abs(targetX) > Math.floor(GRID_WIDTH / 2)) { |
|
|
return { valid: false, reason: 'bounds' }; |
|
|
} |
|
|
|
|
|
const lane = gameState.lanes.get(targetZ); |
|
|
if (lane && lane.t === 'g' && lane.so.includes(targetX)) { |
|
|
return { valid: false, reason: 'tree' }; |
|
|
} |
|
|
|
|
|
return { valid: true, tx: targetX, tz: targetZ }; |
|
|
} |
|
|
|
|
|
|
|
|
function getLeaderboard() { |
|
|
return Array.from(players.values()) |
|
|
.filter(p => p.alive || p.score > 0) |
|
|
.sort((a, b) => b.score - a.score) |
|
|
.slice(0, 3) |
|
|
.map((p, i) => ({ |
|
|
r: i + 1, |
|
|
n: p.name, |
|
|
s: p.score |
|
|
})); |
|
|
} |
|
|
|
|
|
|
|
|
function compactPlayer(p) { |
|
|
return { |
|
|
id: p.id, |
|
|
n: p.name, |
|
|
gx: p.gx, |
|
|
gz: p.gz, |
|
|
wx: Math.round(p.wx * 10) / 10, |
|
|
wz: Math.round(p.wz * 10) / 10, |
|
|
r: Math.round(p.rot * 100) / 100, |
|
|
s: p.score, |
|
|
a: p.alive ? 1 : 0, |
|
|
h: p.hopping ? 1 : 0, |
|
|
ht: p.hopTime |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
function compactLane(lane) { |
|
|
return { |
|
|
i: lane.i, |
|
|
t: lane.t, |
|
|
rl: lane.rl, |
|
|
so: lane.so, |
|
|
sp: lane.sp, |
|
|
d: lane.d, |
|
|
obs: lane.obs.map(o => ({ |
|
|
id: o.id, |
|
|
x: Math.round(o.x * 10) / 10, |
|
|
l: o.l, |
|
|
w: o.w |
|
|
})) |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
io.on('connection', (socket) => { |
|
|
console.log(`[${new Date().toISOString()}] Connection: ${socket.id}`); |
|
|
|
|
|
|
|
|
connectionStats.set(socket.id, { |
|
|
connected: Date.now(), |
|
|
lastPing: Date.now(), |
|
|
ping: 0, |
|
|
quality: 'good' |
|
|
}); |
|
|
|
|
|
|
|
|
socket.emit('ack', { |
|
|
id: socket.id, |
|
|
t: Date.now(), |
|
|
v: gameState.version |
|
|
}); |
|
|
|
|
|
socket.on('join', (data) => { |
|
|
try { |
|
|
const name = (data.name || 'Anon').substring(0, 12).replace(/[<>\"\'&]/g, ''); |
|
|
|
|
|
const player = { |
|
|
id: socket.id, |
|
|
name: name, |
|
|
gx: 0, gz: 0, |
|
|
wx: 0, wz: 0, |
|
|
score: 0, |
|
|
alive: true, |
|
|
hopping: false, |
|
|
hopTime: 0, |
|
|
hopStart: { x: 0, z: 0 }, |
|
|
hopTarget: { x: 0, z: 0 }, |
|
|
rot: 0, |
|
|
logId: null, |
|
|
lastMove: 0, |
|
|
moveSeq: 0 |
|
|
}; |
|
|
|
|
|
players.set(socket.id, player); |
|
|
|
|
|
|
|
|
const lanesArr = []; |
|
|
gameState.lanes.forEach((lane) => { |
|
|
lanesArr.push(compactLane(lane)); |
|
|
}); |
|
|
|
|
|
const otherPlayers = []; |
|
|
players.forEach((p, id) => { |
|
|
if (id !== socket.id) { |
|
|
otherPlayers.push(compactPlayer(p)); |
|
|
} |
|
|
}); |
|
|
|
|
|
socket.emit('init', { |
|
|
p: compactPlayer(player), |
|
|
ps: otherPlayers, |
|
|
ls: lanesArr, |
|
|
t: Date.now(), |
|
|
lb: getLeaderboard() |
|
|
}); |
|
|
|
|
|
|
|
|
socket.broadcast.emit('pj', compactPlayer(player)); |
|
|
|
|
|
console.log(`[${new Date().toISOString()}] Joined: ${name} (${socket.id})`); |
|
|
} catch (err) { |
|
|
console.error('Join error:', err); |
|
|
socket.emit('err', { m: 'Join failed' }); |
|
|
} |
|
|
}); |
|
|
|
|
|
socket.on('m', (data) => { |
|
|
const player = players.get(socket.id); |
|
|
if (!player) return; |
|
|
|
|
|
const now = Date.now(); |
|
|
|
|
|
|
|
|
if (now - player.lastMove < 70) { |
|
|
socket.emit('mr', { s: data.s, r: 'fast' }); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (player.hopping) { |
|
|
socket.emit('mr', { s: data.s, r: 'hop' }); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!player.alive) { |
|
|
socket.emit('mr', { s: data.s, r: 'dead' }); |
|
|
return; |
|
|
} |
|
|
|
|
|
const { dx, dz, s } = data; |
|
|
const v = validateMove(player, dx, dz); |
|
|
|
|
|
if (!v.valid) { |
|
|
socket.emit('mr', { s, r: v.reason }); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
player.lastMove = now; |
|
|
player.hopping = true; |
|
|
player.hopTime = now; |
|
|
player.hopStart = { x: player.wx, z: player.wz }; |
|
|
player.hopTarget = { x: v.tx * TILE_SIZE, z: v.tz * TILE_SIZE }; |
|
|
player.gx = v.tx; |
|
|
player.gz = v.tz; |
|
|
player.wx = v.tx * TILE_SIZE; |
|
|
player.wz = v.tz * TILE_SIZE; |
|
|
player.logId = null; |
|
|
player.moveSeq = s; |
|
|
|
|
|
|
|
|
if (dx === 1) player.rot = -Math.PI / 2; |
|
|
else if (dx === -1) player.rot = Math.PI / 2; |
|
|
else if (dz === 1) player.rot = 0; |
|
|
else if (dz === -1) player.rot = Math.PI; |
|
|
|
|
|
|
|
|
if (v.tz > player.score) { |
|
|
player.score = v.tz; |
|
|
io.emit('lb', getLeaderboard()); |
|
|
} |
|
|
|
|
|
|
|
|
const spawnZ = player.score + 12; |
|
|
while (spawnZ > gameState.furthestLaneIndex) { |
|
|
gameState.furthestLaneIndex++; |
|
|
const prevLane = gameState.lanes.get(gameState.furthestLaneIndex - 1); |
|
|
const newLane = generateLaneData(gameState.furthestLaneIndex, prevLane); |
|
|
gameState.lanes.set(gameState.furthestLaneIndex, newLane); |
|
|
io.emit('nl', compactLane(newLane)); |
|
|
} |
|
|
|
|
|
|
|
|
socket.emit('mc', { |
|
|
s, |
|
|
gx: player.gx, |
|
|
gz: player.gz, |
|
|
sc: player.score, |
|
|
t: now |
|
|
}); |
|
|
|
|
|
|
|
|
socket.broadcast.emit('pm', { |
|
|
id: player.id, |
|
|
gx: player.gx, |
|
|
gz: player.gz, |
|
|
wx: player.wx, |
|
|
wz: player.wz, |
|
|
r: player.rot, |
|
|
t: now, |
|
|
hs: player.hopStart, |
|
|
ht: player.hopTarget |
|
|
}); |
|
|
}); |
|
|
|
|
|
socket.on('p', (ct) => { |
|
|
const stats = connectionStats.get(socket.id); |
|
|
if (stats) { |
|
|
stats.lastPing = Date.now(); |
|
|
} |
|
|
socket.emit('po', { c: ct, s: Date.now() }); |
|
|
}); |
|
|
|
|
|
socket.on('rs', () => { |
|
|
const player = players.get(socket.id); |
|
|
if (!player) return; |
|
|
|
|
|
player.gx = 0; |
|
|
player.gz = 0; |
|
|
player.wx = 0; |
|
|
player.wz = 0; |
|
|
player.score = 0; |
|
|
player.alive = true; |
|
|
player.hopping = false; |
|
|
player.logId = null; |
|
|
player.rot = 0; |
|
|
|
|
|
socket.emit('rsd', compactPlayer(player)); |
|
|
socket.broadcast.emit('prs', { id: player.id }); |
|
|
io.emit('lb', getLeaderboard()); |
|
|
}); |
|
|
|
|
|
socket.on('hb', () => { |
|
|
socket.emit('hba'); |
|
|
}); |
|
|
|
|
|
socket.on('disconnect', (reason) => { |
|
|
console.log(`[${new Date().toISOString()}] Disconnect: ${socket.id} (${reason})`); |
|
|
players.delete(socket.id); |
|
|
connectionStats.delete(socket.id); |
|
|
io.emit('pl', socket.id); |
|
|
io.emit('lb', getLeaderboard()); |
|
|
}); |
|
|
|
|
|
socket.on('error', (err) => { |
|
|
console.error(`Socket error ${socket.id}:`, err); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
let lastTick = Date.now(); |
|
|
|
|
|
function gameTick() { |
|
|
const now = Date.now(); |
|
|
const dt = now - lastTick; |
|
|
lastTick = now; |
|
|
|
|
|
|
|
|
gameState.lanes.forEach((lane) => { |
|
|
if (lane.t === 'r' || lane.t === 'w') { |
|
|
lane.timer += dt; |
|
|
|
|
|
if (lane.timer > lane.interval) { |
|
|
const startX = -lane.d * (GRID_WIDTH * TILE_SIZE / 2 + 60); |
|
|
const obsWidth = lane.t === 'w' ? (Math.random() > 0.5 ? 40 : 60) : 12; |
|
|
|
|
|
lane.obs.push({ |
|
|
id: lane.obsId++, |
|
|
x: startX, |
|
|
l: lane.t === 'w' ? 1 : 0, |
|
|
w: obsWidth |
|
|
}); |
|
|
|
|
|
lane.timer = 0; |
|
|
lane.interval = Math.random() * 100 + 200; |
|
|
} |
|
|
|
|
|
|
|
|
for (let i = lane.obs.length - 1; i >= 0; i--) { |
|
|
const obs = lane.obs[i]; |
|
|
obs.x += lane.sp * lane.d * (dt / 16.67); |
|
|
if (Math.abs(obs.x) > 400) { |
|
|
lane.obs.splice(i, 1); |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
players.forEach((player) => { |
|
|
if (!player.alive) return; |
|
|
|
|
|
|
|
|
if (player.hopping && now - player.hopTime >= HOP_DURATION) { |
|
|
player.hopping = false; |
|
|
|
|
|
const col = checkCollisionAtPosition(player.gx, player.gz, player.wx); |
|
|
|
|
|
if (col.hit) { |
|
|
player.alive = false; |
|
|
io.emit('pd', { id: player.id, type: col.type }); |
|
|
io.emit('lb', getLeaderboard()); |
|
|
} else if (col.onLog) { |
|
|
player.logId = col.logId; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (player.hopping) { |
|
|
const progress = (now - player.hopTime) / HOP_DURATION; |
|
|
const cx = player.hopStart.x + (player.hopTarget.x - player.hopStart.x) * progress; |
|
|
const cz = player.hopStart.z + (player.hopTarget.z - player.hopStart.z) * progress; |
|
|
const cgz = Math.round(cz / TILE_SIZE); |
|
|
|
|
|
const lane = gameState.lanes.get(cgz); |
|
|
if (lane && lane.t === 'r') { |
|
|
for (const obs of lane.obs) { |
|
|
if (Math.abs(cx - obs.x) < (obs.w / 2 + 3)) { |
|
|
player.alive = false; |
|
|
player.hopping = false; |
|
|
io.emit('pd', { id: player.id, type: 'car' }); |
|
|
io.emit('lb', getLeaderboard()); |
|
|
break; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (!player.hopping && player.logId !== null) { |
|
|
const lane = gameState.lanes.get(player.gz); |
|
|
if (lane) { |
|
|
const log = lane.obs.find(o => o.id === player.logId); |
|
|
if (log) { |
|
|
player.wx = log.x; |
|
|
player.gx = Math.round(player.wx / TILE_SIZE); |
|
|
|
|
|
if (Math.abs(player.wx) > (GRID_WIDTH * TILE_SIZE / 2 + 10)) { |
|
|
player.alive = false; |
|
|
io.emit('pd', { id: player.id, type: 'bounds' }); |
|
|
io.emit('lb', getLeaderboard()); |
|
|
} |
|
|
} else { |
|
|
player.logId = null; |
|
|
if (lane.t === 'w') { |
|
|
player.alive = false; |
|
|
io.emit('pd', { id: player.id, type: 'water' }); |
|
|
io.emit('lb', getLeaderboard()); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
const playersData = []; |
|
|
players.forEach(p => playersData.push(compactPlayer(p))); |
|
|
|
|
|
const obsData = {}; |
|
|
gameState.lanes.forEach((lane, key) => { |
|
|
if (lane.t !== 'g' && lane.obs.length > 0) { |
|
|
obsData[key] = lane.obs.map(o => ({ |
|
|
id: o.id, |
|
|
x: Math.round(o.x * 10) / 10 |
|
|
})); |
|
|
} |
|
|
}); |
|
|
|
|
|
io.emit('gs', { |
|
|
ps: playersData, |
|
|
obs: obsData, |
|
|
t: now |
|
|
}); |
|
|
} |
|
|
|
|
|
setInterval(gameTick, 1000 / BASE_TICK_RATE); |
|
|
|
|
|
|
|
|
setInterval(() => { |
|
|
let minZ = Infinity; |
|
|
players.forEach(p => { |
|
|
if (p.gz < minZ) minZ = p.gz; |
|
|
}); |
|
|
if (minZ === Infinity) minZ = 0; |
|
|
|
|
|
gameState.lanes.forEach((lane, key) => { |
|
|
if (key < minZ - 15) { |
|
|
gameState.lanes.delete(key); |
|
|
} |
|
|
}); |
|
|
}, 10000); |
|
|
|
|
|
|
|
|
app.get('/health', (req, res) => { |
|
|
res.json({ |
|
|
status: 'ok', |
|
|
players: players.size, |
|
|
uptime: process.uptime() |
|
|
}); |
|
|
}); |
|
|
|
|
|
const PORT = process.env.PORT || 7860; |
|
|
server.listen(PORT, '0.0.0.0', () => { |
|
|
console.log(`Server running on port ${PORT}`); |
|
|
}); |