| | <!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>Void City 3D - Multiplayer</title> |
| | <style> |
| | * { box-sizing: border-box; user-select: none; -webkit-user-select: none; touch-action: none; } |
| | body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: #1a1a1a; font-family: -apple-system, sans-serif; } |
| | #gameCanvas { display: block; width: 100%; height: 100%; } |
| | #uiLayer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } |
| | |
| | #scoreBoard { position: absolute; top: 20px; right: 20px; text-align: right; color: white; text-shadow: 0 2px 4px rgba(0,0,0,0.8); } |
| | .score-row { margin-bottom: 5px; font-weight: bold; font-size: 16px; display: flex; align-items: center; justify-content: flex-end; } |
| | .lb-avatar { width: 20px; height: 20px; border-radius: 50%; margin-right: 8px; border: 1px solid white; } |
| | |
| | #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; } |
| | .discord-btn { background: #5865F2; color: white; padding: 15px 30px; border-radius: 5px; font-size: 20px; font-weight: bold; border: none; cursor: pointer; } |
| | |
| | #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; } |
| | .res-entry { font-size: 24px; margin: 10px; display: flex; align-items: center; } |
| | </style> |
| | <script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script> |
| | </head> |
| | <body> |
| |
|
| | <canvas id="gameCanvas"></canvas> |
| |
|
| | <div id="uiLayer"> |
| | <div id="scoreBoard">Connecting...</div> |
| | </div> |
| |
|
| | <div id="loginScreen"> |
| | <h1 style="color:white; font-size:50px; margin-bottom:30px;">VOID CITY 3D</h1> |
| | <button class="discord-btn" onclick="login()">Login with Discord</button> |
| | </div> |
| |
|
| | <div id="resultsScreen"> |
| | <h1>TOURNAMENT ENDED</h1> |
| | <div id="resultsList"></div> |
| | </div> |
| |
|
| | <script> |
| | const COLORS = { ground: '#95a5a6', road: '#2c3e50', marking: '#f1c40f', crosswalk: '#ecf0f1' }; |
| | const GRID_SIZE = 8; |
| | const BLOCK_SIZE = 280; |
| | const ROAD_WIDTH = 90; |
| | const CELL_SIZE = BLOCK_SIZE + ROAD_WIDTH; |
| | |
| | |
| | const urlParams = new URLSearchParams(window.location.search); |
| | const token = urlParams.get('token') || sessionStorage.getItem('game_token'); |
| | if (token) { |
| | sessionStorage.setItem('game_token', token); |
| | document.getElementById('loginScreen').style.display = 'none'; |
| | window.history.replaceState({}, document.title, "/"); |
| | } |
| | function login() { window.location.href = '/login'; } |
| | |
| | |
| | const canvas = document.getElementById('gameCanvas'); |
| | const ctx = canvas.getContext('2d', { alpha: false }); |
| | let socket; |
| | let mapConfig = { width: 3000, height: 3000 }; |
| | |
| | const camera = { x: 0, y: 0, zoom: 1 }; |
| | let staticEntities = []; |
| | let cars = []; |
| | |
| | |
| | let playerCache = {}; |
| | let players = []; |
| | let myId = null; |
| | |
| | const input = { active: false, startX:0, startY:0, currX:0, currY:0 }; |
| | |
| | function resize() { |
| | canvas.width = window.innerWidth; |
| | canvas.height = window.innerHeight; |
| | } |
| | window.addEventListener('resize', resize); |
| | resize(); |
| | |
| | if (token) { |
| | socket = io({ auth: { token: token } }); |
| | socket.on('connect', () => { myId = token; }); |
| | |
| | |
| | socket.on('init_game', (data) => { |
| | mapConfig.width = data.width; |
| | mapConfig.height = data.height; |
| | staticEntities = data.static_entities; |
| | |
| | data.players_meta.forEach(p => playerCache[p.id] = p); |
| | }); |
| | |
| | |
| | socket.on('player_joined', (meta) => { playerCache[meta.id] = meta; }); |
| | socket.on('player_left', (data) => { delete playerCache[data.id]; }); |
| | |
| | |
| | socket.on('game_update', (state) => { |
| | |
| | players = state.players.map(p => { |
| | const meta = playerCache[p.id] || { username: 'Unknown', color: '#fff', avatar: '' }; |
| | return { ...p, ...meta }; |
| | }); |
| | |
| | cars = state.cars; |
| | |
| | if(state.removed.length > 0) { |
| | staticEntities = staticEntities.filter(e => !state.removed.includes(e.id)); |
| | } |
| | updateUI(); |
| | }); |
| | |
| | socket.on('tournament_reset', (data) => { |
| | staticEntities = data.static_entities; |
| | document.getElementById('resultsScreen').style.display = 'none'; |
| | }); |
| | socket.on('tournament_end', (data) => { |
| | const list = document.getElementById('resultsList'); |
| | list.innerHTML = data.results.map((p, i) => ` |
| | <div class="res-entry">#${i+1} <img src="${p.avatar}" style="width:30px;border-radius:50%;margin:0 10px">${p.username}: ${p.score}</div> |
| | `).join(''); |
| | document.getElementById('resultsScreen').style.display = 'flex'; |
| | }); |
| | } |
| | |
| | |
| | function sendInput() { |
| | if (!socket || !input.active) { |
| | if(socket) socket.emit('input_data', { angle: 0, force: 0 }); |
| | return; |
| | } |
| | const dx = input.currX - input.startX; |
| | const dy = input.currY - input.startY; |
| | const angle = Math.atan2(dy, dx); |
| | const force = Math.min(Math.hypot(dx, dy) / 50, 1); |
| | socket.emit('input_data', { angle, force }); |
| | } |
| | setInterval(sendInput, 50); |
| | |
| | function handleStart(x, y) { input.active = true; input.startX = x; input.startY = y; input.currX = x; input.currY = y; } |
| | function handleMove(x, y) { if(input.active) { input.currX = x; input.currY = y; } } |
| | function handleEnd() { input.active = false; } |
| | |
| | canvas.addEventListener('mousedown', e => handleStart(e.clientX, e.clientY)); |
| | window.addEventListener('mousemove', e => handleMove(e.clientX, e.clientY)); |
| | window.addEventListener('mouseup', handleEnd); |
| | canvas.addEventListener('touchstart', e => { e.preventDefault(); handleStart(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false}); |
| | canvas.addEventListener('touchmove', e => { e.preventDefault(); handleMove(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false}); |
| | canvas.addEventListener('touchend', handleEnd); |
| | |
| | |
| | function drawCityFloor() { |
| | ctx.fillStyle = COLORS.ground; |
| | ctx.fillRect(0, 0, mapConfig.width, mapConfig.height); |
| | |
| | for(let iy=0; iy<=GRID_SIZE; iy++) { |
| | const y = iy * CELL_SIZE; |
| | ctx.fillStyle = COLORS.road; |
| | ctx.fillRect(0, y - ROAD_WIDTH/2, mapConfig.width, ROAD_WIDTH); |
| | ctx.strokeStyle = COLORS.marking; ctx.lineWidth = 2; ctx.setLineDash([20, 20]); |
| | ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(mapConfig.width, y); ctx.stroke(); ctx.setLineDash([]); |
| | for(let ix=0; ix<=GRID_SIZE; ix++) { |
| | const x = ix * CELL_SIZE; ctx.fillStyle = COLORS.crosswalk; |
| | for(let k=-ROAD_WIDTH/2; k<ROAD_WIDTH/2; k+=10) { |
| | ctx.fillRect(x - ROAD_WIDTH/2 - 10, y + k, 8, 5); ctx.fillRect(x + ROAD_WIDTH/2 + 2, y + k, 8, 5); |
| | } |
| | } |
| | } |
| | for(let ix=0; ix<=GRID_SIZE; ix++) { |
| | const x = ix * CELL_SIZE; |
| | ctx.fillStyle = COLORS.road; ctx.fillRect(x - ROAD_WIDTH/2, 0, ROAD_WIDTH, mapConfig.height); |
| | ctx.strokeStyle = COLORS.marking; ctx.lineWidth = 2; ctx.setLineDash([20, 20]); |
| | ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, mapConfig.height); ctx.stroke(); ctx.setLineDash([]); |
| | for(let iy=0; iy<=GRID_SIZE; iy++) { |
| | const y = iy * CELL_SIZE; ctx.fillStyle = COLORS.crosswalk; |
| | for(let k=-ROAD_WIDTH/2; k<ROAD_WIDTH/2; k+=10) { |
| | ctx.fillRect(x + k, y - ROAD_WIDTH/2 - 10, 5, 8); ctx.fillRect(x + k, y + ROAD_WIDTH/2 + 2, 5, 8); |
| | } |
| | } |
| | } |
| | } |
| | |
| | function drawBox(ctx, x, y, w, l, h, rotation, color, rx, ry, isFalling) { |
| | const hw = w/2; const hl = l/2; |
| | const b_tl = {x: -hw, y: -hl}; const b_tr = {x: hw, y: -hl}; |
| | const b_br = {x: hw, y: hl}; const b_bl = {x: -hw, y: hl}; |
| | 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}; |
| | 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}; |
| | |
| | const drawFace = (p1, p2, p3, p4, c) => { |
| | ctx.fillStyle = c; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); |
| | ctx.lineTo(p3.x, p3.y); ctx.lineTo(p4.x, p4.y); ctx.closePath(); ctx.fill(); |
| | }; |
| | drawFace(b_tl, r_tl, r_tr, b_tr, color); drawFace(b_tr, r_tr, r_br, b_br, color); |
| | drawFace(b_br, r_br, r_bl, b_bl, color); drawFace(b_bl, r_bl, r_tl, b_tl, color); |
| | |
| | ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(r_tl.x, r_tl.y); ctx.lineTo(r_tr.x, r_tr.y); |
| | ctx.lineTo(r_br.x, r_br.y); ctx.lineTo(r_bl.x, r_bl.y); ctx.closePath(); ctx.fill(); |
| | ctx.fillStyle = 'rgba(255,255,255,0.1)'; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.lineWidth = 1; ctx.stroke(); |
| | } |
| | |
| | function draw3DEntity(ent, falling) { |
| | ctx.save(); ctx.translate(ent.x, ent.y); |
| | const h = ent.h || 10; const TILT_FACTOR = 1.2; const PERSPECTIVE_X = 0.0003; |
| | const rx_screen = (ent.x - camera.x) * (h * PERSPECTIVE_X); |
| | const ry_screen = -h * TILT_FACTOR; |
| | const ang = -(ent.rotation || 0); |
| | const rx = rx_screen * Math.cos(ang) - ry_screen * Math.sin(ang); |
| | const ry = rx_screen * Math.sin(ang) + ry_screen * Math.cos(ang); |
| | |
| | if (ent.type === 'car') { |
| | ctx.rotate(ent.rotation || 0); |
| | if (!falling) { |
| | ctx.fillStyle = '#111'; |
| | const wx = ent.w/2 - 8; const wy = ent.l/2; |
| | ctx.fillRect(wx, -wy-2, 6, 4); ctx.fillRect(wx, wy-2, 6, 4); |
| | ctx.fillRect(-wx, -wy-2, 6, 4); ctx.fillRect(-wx, wy-2, 6, 4); |
| | } |
| | drawBox(ctx, 0, 0, ent.w, ent.l, ent.h, 0, ent.color, rx, ry, falling); |
| | |
| | const ch = ent.cabinH || 10; |
| | const rx_screen_cab = (ent.x - camera.x) * (ch * PERSPECTIVE_X); |
| | const ry_screen_cab = -ch * TILT_FACTOR; |
| | const rx_cab = rx_screen_cab * Math.cos(ang) - ry_screen_cab * Math.sin(ang); |
| | const ry_cab = rx_screen_cab * Math.sin(ang) + ry_screen_cab * Math.cos(ang); |
| | |
| | ctx.translate(rx, ry); |
| | const cw = ent.cabinW; const cl = ent.cabinL; |
| | const hw = cw/2; const hl = cl/2; |
| | const b_tl = {x: -hw, y: -hl}; const b_tr = {x: hw, y: -hl}; |
| | const b_br = {x: hw, y: hl}; const b_bl = {x: -hw, y: hl}; |
| | 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}; |
| | 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}; |
| | |
| | const drawPoly = (pts, color) => { |
| | ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); |
| | for(let i=1; i<pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); ctx.closePath(); ctx.fill(); |
| | }; |
| | drawPoly([b_tl, r_tl, r_tr, b_tr], '#34495e'); drawPoly([b_tr, r_tr, r_br, b_br], '#34495e'); |
| | drawPoly([b_br, r_br, r_bl, b_bl], '#34495e'); drawPoly([b_bl, r_bl, r_tl, b_tl], '#34495e'); |
| | drawPoly([r_tl, r_tr, r_br, r_bl], '#34495e'); ctx.fillStyle = 'rgba(255,255,255,0.1)'; ctx.fill(); |
| | |
| | } else if (ent.type === 'building') { |
| | ctx.rotate(ent.rotation || 0); |
| | const w = ent.w; const l = ent.l; const hw = w/2; const hl = l/2; |
| | const b_tl = {x: -hw, y: -hl}; const b_tr = {x: hw, y: -hl}; |
| | const b_br = {x: hw, y: hl}; const b_bl = {x: -hw, y: hl}; |
| | 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}; |
| | 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}; |
| | |
| | const drawFace = (p1, p2, p3, p4, color, isSide) => { |
| | ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); |
| | ctx.lineTo(p3.x, p3.y); ctx.lineTo(p4.x, p4.y); ctx.closePath(); ctx.fill(); |
| | }; |
| | const sideCol = ent.sideColor || ent.color; |
| | drawFace(b_tl, r_tl, r_tr, b_tr, sideCol, false); drawFace(b_tr, r_tr, r_br, b_br, sideCol, true); |
| | drawFace(b_br, r_br, r_bl, b_bl, sideCol, true); drawFace(b_bl, r_bl, r_tl, b_tl, sideCol, true); |
| | ctx.fillStyle = ent.color; ctx.beginPath(); ctx.moveTo(r_tl.x, r_tl.y); ctx.lineTo(r_tr.x, r_tr.y); |
| | ctx.lineTo(r_br.x, r_br.y); ctx.lineTo(r_bl.x, r_bl.y); ctx.closePath(); ctx.fill(); |
| | ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 2; ctx.stroke(); |
| | |
| | } else { |
| | const rx = (ent.x - camera.x) * (ent.h * PERSPECTIVE_X); |
| | const ry = -ent.h * TILT_FACTOR; |
| | ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.beginPath(); ctx.arc(0,0, ent.r, 0, Math.PI*2); ctx.fill(); |
| | |
| | if(ent.type === 'rock') { |
| | ctx.fillStyle = ent.color; ctx.beginPath(); |
| | ctx.moveTo(0, -ent.r + ry); ctx.lineTo(ent.r, 0 + ry); |
| | ctx.lineTo(0, ent.r + ry); ctx.lineTo(-ent.r, 0 + ry); ctx.fill(); |
| | } else if (ent.type === 'flower') { |
| | ctx.strokeStyle = '#27ae60'; ctx.lineWidth = 2; |
| | ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(rx, ry); ctx.stroke(); |
| | ctx.fillStyle = ent.color; ctx.beginPath(); ctx.arc(rx, ry, ent.r, 0, Math.PI*2); ctx.fill(); |
| | ctx.fillStyle = 'yellow'; ctx.beginPath(); ctx.arc(rx, ry, ent.r/2, 0, Math.PI*2); ctx.fill(); |
| | } else if (ent.type === 'tree') { |
| | const layers = 4; ctx.strokeStyle = '#5d4037'; ctx.lineWidth = ent.r * 0.4; |
| | ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(rx*0.5, ry*0.5); ctx.stroke(); |
| | for(let i=0; i<layers; i++) { |
| | const ratio = i / (layers-1); |
| | const lrx = rx * (0.2 + ratio * 0.8); const lry = ry * (0.2 + ratio * 0.8); |
| | ctx.fillStyle = i % 2 === 0 ? ent.color : '#2ecc71'; |
| | const size = ent.r * (1.2 - ratio * 0.4); |
| | ctx.beginPath(); ctx.arc(lrx, lry, size, 0, Math.PI*2); ctx.fill(); |
| | } |
| | } else { |
| | ctx.lineWidth = ent.r * 2; ctx.strokeStyle = ent.sideColor || ent.color; ctx.lineCap = 'round'; |
| | ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(rx, ry); ctx.stroke(); |
| | ctx.fillStyle = ent.color; ctx.beginPath(); ctx.arc(rx, ry, ent.r, 0, Math.PI*2); ctx.fill(); |
| | } |
| | } |
| | ctx.restore(); |
| | } |
| | |
| | function render() { |
| | if (!socket) { requestAnimationFrame(render); return; } |
| | |
| | ctx.fillStyle = '#111'; |
| | ctx.fillRect(0, 0, canvas.width, canvas.height); |
| | |
| | const me = players.find(p => p.id === myId); |
| | if (me) { |
| | camera.x += (me.x - camera.x) * 0.1; |
| | camera.y += (me.y - camera.y) * 0.1; |
| | const targetZoom = Math.max(0.3, Math.min(1.0, 400 / (me.r * 2))); |
| | camera.zoom += (targetZoom - camera.zoom) * 0.05; |
| | } |
| | |
| | ctx.save(); |
| | ctx.translate(canvas.width/2, canvas.height/2); |
| | ctx.scale(camera.zoom, camera.zoom); |
| | ctx.translate(-camera.x, -camera.y); |
| | |
| | drawCityFloor(); |
| | |
| | |
| | players.forEach(p => { |
| | if(!p.alive) return; |
| | ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI*2); |
| | ctx.fillStyle = 'black'; ctx.fill(); |
| | ctx.strokeStyle = p.color; ctx.lineWidth = 4; ctx.stroke(); |
| | ctx.fillStyle = 'white'; ctx.font = `bold ${Math.max(14, p.r/2)}px Arial`; |
| | ctx.textAlign = 'center'; ctx.fillText(p.username, p.x, p.y - p.r - 10); |
| | }); |
| | |
| | |
| | ctx.save(); |
| | ctx.beginPath(); |
| | ctx.rect(camera.x - (canvas.width/camera.zoom), camera.y - (canvas.height/camera.zoom), canvas.width*2/camera.zoom, canvas.height*2/camera.zoom); |
| | 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); } }); |
| | ctx.clip(); |
| | |
| | |
| | const viewW = (canvas.width / camera.zoom) / 2 + 200; |
| | const viewH = (canvas.height / camera.zoom) / 2 + 200; |
| | const visibleStatic = staticEntities.filter(e => Math.abs(e.x - camera.x) < viewW && Math.abs(e.y - camera.y) < viewH); |
| | const allEnts = [...visibleStatic, ...cars]; |
| | allEnts.sort((a,b) => (a.y + (a.l||0)/2) - (b.y + (b.l||0)/2)); |
| | allEnts.forEach(ent => draw3DEntity(ent, false)); |
| | |
| | ctx.restore(); ctx.restore(); |
| | |
| | |
| | if (input.active) { |
| | ctx.beginPath(); ctx.arc(input.startX, input.startY, 50, 0, Math.PI*2); |
| | ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 2; ctx.stroke(); |
| | ctx.beginPath(); ctx.arc(input.currX, input.currY, 20, 0, Math.PI*2); |
| | ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fill(); |
| | } |
| | requestAnimationFrame(render); |
| | } |
| | |
| | function updateUI() { |
| | const sb = document.getElementById('scoreBoard'); |
| | const sorted = [...players].sort((a,b) => b.score - a.score).slice(0, 5); |
| | sb.innerHTML = sorted.map(p => ` |
| | <div class="score-row"> |
| | ${p.avatar ? `<img src="${p.avatar}" class="lb-avatar">` : ''} |
| | ${p.username}: ${p.score} |
| | </div> |
| | `).join(''); |
| | } |
| | render(); |
| | </script> |
| | </body> |
| | </html> |