| | <!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>Voxel Hopper: Multiplayer</title> |
| | <style> |
| | * { box-sizing: border-box; margin: 0; padding: 0; } |
| | |
| | body, html { |
| | width: 100%; |
| | height: 100%; |
| | background: #1a1a2e; |
| | overflow: hidden; |
| | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| | touch-action: none; |
| | user-select: none; |
| | -webkit-user-select: none; |
| | -webkit-touch-callout: none; |
| | } |
| | |
| | #game-container { |
| | position: relative; |
| | width: 100%; |
| | height: 100%; |
| | background: #87CEEB; |
| | } |
| | |
| | canvas { display: block; width: 100%; height: 100%; } |
| | |
| | |
| | #login-screen { |
| | position: absolute; |
| | inset: 0; |
| | background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); |
| | display: flex; |
| | flex-direction: column; |
| | justify-content: center; |
| | align-items: center; |
| | z-index: 1000; |
| | padding: 20px; |
| | } |
| | |
| | .login-content { |
| | text-align: center; |
| | max-width: 320px; |
| | width: 100%; |
| | } |
| | |
| | .game-logo { |
| | font-size: 64px; |
| | margin-bottom: 10px; |
| | animation: bounce 2s infinite; |
| | } |
| | |
| | @keyframes bounce { |
| | 0%, 100% { transform: translateY(0); } |
| | 50% { transform: translateY(-10px); } |
| | } |
| | |
| | .login-content h1 { |
| | color: #fff; |
| | font-size: 32px; |
| | margin-bottom: 5px; |
| | text-shadow: 2px 2px 0 #000; |
| | } |
| | |
| | .login-content .subtitle { |
| | color: #4CAF50; |
| | font-size: 14px; |
| | margin-bottom: 30px; |
| | text-transform: uppercase; |
| | letter-spacing: 2px; |
| | } |
| | |
| | #username-input { |
| | width: 100%; |
| | padding: 16px 20px; |
| | font-size: 18px; |
| | border: 3px solid #333; |
| | border-radius: 12px; |
| | background: #fff; |
| | text-align: center; |
| | font-family: inherit; |
| | outline: none; |
| | transition: border-color 0.2s; |
| | } |
| | |
| | #username-input:focus { |
| | border-color: #4CAF50; |
| | } |
| | |
| | #join-btn { |
| | width: 100%; |
| | padding: 16px; |
| | margin-top: 15px; |
| | font-size: 18px; |
| | font-weight: bold; |
| | background: linear-gradient(180deg, #4CAF50 0%, #388E3C 100%); |
| | color: white; |
| | border: none; |
| | border-radius: 12px; |
| | cursor: pointer; |
| | box-shadow: 0 4px 0 #2E7D32; |
| | transition: all 0.1s; |
| | text-transform: uppercase; |
| | letter-spacing: 1px; |
| | } |
| | |
| | #join-btn:active:not(:disabled) { |
| | transform: translateY(2px); |
| | box-shadow: 0 2px 0 #2E7D32; |
| | } |
| | |
| | #join-btn:disabled { |
| | background: linear-gradient(180deg, #666 0%, #444 100%); |
| | box-shadow: 0 4px 0 #333; |
| | cursor: not-allowed; |
| | } |
| | |
| | |
| | .connection-box { |
| | margin-top: 20px; |
| | padding: 12px 20px; |
| | border-radius: 8px; |
| | font-size: 13px; |
| | display: flex; |
| | align-items: center; |
| | justify-content: center; |
| | gap: 8px; |
| | transition: all 0.3s; |
| | } |
| | |
| | .connection-box.connecting { |
| | background: rgba(255, 152, 0, 0.2); |
| | color: #FFB74D; |
| | } |
| | |
| | .connection-box.connected { |
| | background: rgba(76, 175, 80, 0.2); |
| | color: #81C784; |
| | } |
| | |
| | .connection-box.error { |
| | background: rgba(244, 67, 54, 0.2); |
| | color: #E57373; |
| | } |
| | |
| | .connection-dot { |
| | width: 8px; |
| | height: 8px; |
| | border-radius: 50%; |
| | animation: pulse-dot 1.5s infinite; |
| | } |
| | |
| | .connecting .connection-dot { background: #FF9800; } |
| | .connected .connection-dot { background: #4CAF50; animation: none; } |
| | .error .connection-dot { background: #f44336; } |
| | |
| | @keyframes pulse-dot { |
| | 0%, 100% { opacity: 1; transform: scale(1); } |
| | 50% { opacity: 0.5; transform: scale(0.8); } |
| | } |
| | |
| | .retry-info { |
| | color: #888; |
| | font-size: 11px; |
| | margin-top: 8px; |
| | } |
| | |
| | |
| | .spinner { |
| | width: 20px; |
| | height: 20px; |
| | border: 2px solid rgba(255,255,255,0.3); |
| | border-top-color: #fff; |
| | border-radius: 50%; |
| | animation: spin 0.8s linear infinite; |
| | } |
| | |
| | @keyframes spin { to { transform: rotate(360deg); } } |
| | |
| | |
| | .ui-layer { |
| | position: absolute; |
| | inset: 0; |
| | pointer-events: none; |
| | } |
| | |
| | #score { |
| | position: absolute; |
| | top: 50px; |
| | left: 20px; |
| | font-size: 72px; |
| | font-weight: 900; |
| | color: white; |
| | text-shadow: 3px 3px 0 rgba(0,0,0,0.3); |
| | line-height: 1; |
| | } |
| | |
| | |
| | #leaderboard { |
| | position: absolute; |
| | top: 55px; |
| | right: 10px; |
| | background: rgba(0,0,0,0.7); |
| | padding: 10px 12px; |
| | border-radius: 10px; |
| | min-width: 120px; |
| | backdrop-filter: blur(10px); |
| | -webkit-backdrop-filter: blur(10px); |
| | } |
| | |
| | #leaderboard h3 { |
| | color: #FFD700; |
| | font-size: 12px; |
| | margin-bottom: 6px; |
| | text-transform: uppercase; |
| | letter-spacing: 1px; |
| | } |
| | |
| | .lb-entry { |
| | display: flex; |
| | justify-content: space-between; |
| | color: white; |
| | font-size: 12px; |
| | padding: 2px 0; |
| | } |
| | |
| | .lb-entry.gold { color: #FFD700; } |
| | .lb-entry.silver { color: #C0C0C0; } |
| | .lb-entry.bronze { color: #CD7F32; } |
| | |
| | .lb-name { |
| | max-width: 80px; |
| | overflow: hidden; |
| | text-overflow: ellipsis; |
| | white-space: nowrap; |
| | } |
| | |
| | |
| | #net-stats { |
| | position: absolute; |
| | bottom: 8px; |
| | left: 8px; |
| | display: flex; |
| | gap: 10px; |
| | font-size: 10px; |
| | color: rgba(255,255,255,0.5); |
| | } |
| | |
| | .stat { display: flex; align-items: center; gap: 4px; } |
| | .stat-dot { |
| | width: 6px; |
| | height: 6px; |
| | border-radius: 50%; |
| | } |
| | .stat-dot.good { background: #4CAF50; } |
| | .stat-dot.medium { background: #FF9800; } |
| | .stat-dot.poor { background: #f44336; } |
| | |
| | |
| | #player-count { |
| | position: absolute; |
| | top: 130px; |
| | left: 20px; |
| | color: rgba(255,255,255,0.6); |
| | font-size: 12px; |
| | } |
| | |
| | |
| | #tutorial { |
| | position: absolute; |
| | bottom: 100px; |
| | left: 50%; |
| | transform: translateX(-50%); |
| | color: white; |
| | font-weight: bold; |
| | font-size: 20px; |
| | text-transform: uppercase; |
| | text-shadow: 2px 2px 0 rgba(0,0,0,0.3); |
| | animation: pulse 1.5s infinite; |
| | } |
| | |
| | @keyframes pulse { |
| | 0%, 100% { opacity: 0.7; } |
| | 50% { opacity: 1; transform: translateX(-50%) scale(1.05); } |
| | } |
| | |
| | |
| | #game-over { |
| | position: absolute; |
| | inset: 0; |
| | background: rgba(0,0,0,0.7); |
| | display: flex; |
| | flex-direction: column; |
| | justify-content: center; |
| | align-items: center; |
| | opacity: 0; |
| | pointer-events: none; |
| | transition: opacity 0.3s; |
| | z-index: 100; |
| | } |
| | |
| | #game-over.visible { |
| | opacity: 1; |
| | pointer-events: auto; |
| | } |
| | |
| | #game-over h1 { |
| | color: white; |
| | font-size: 56px; |
| | text-shadow: 4px 4px 0 #000; |
| | margin-bottom: 10px; |
| | } |
| | |
| | #final-score { |
| | color: #FFD700; |
| | font-size: 24px; |
| | margin-bottom: 30px; |
| | } |
| | |
| | .btn-restart { |
| | background: #fff; |
| | color: #333; |
| | border: none; |
| | padding: 18px 50px; |
| | font-size: 20px; |
| | font-weight: 900; |
| | text-transform: uppercase; |
| | border-radius: 12px; |
| | box-shadow: 0 6px 0 #999; |
| | cursor: pointer; |
| | transition: transform 0.1s; |
| | } |
| | |
| | .btn-restart:active { |
| | transform: translateY(3px); |
| | box-shadow: 0 3px 0 #999; |
| | } |
| | |
| | |
| | #labels-container { |
| | position: absolute; |
| | inset: 0; |
| | pointer-events: none; |
| | overflow: hidden; |
| | } |
| | |
| | .player-label { |
| | position: absolute; |
| | color: white; |
| | font-size: 11px; |
| | font-weight: bold; |
| | text-shadow: 1px 1px 2px #000, -1px -1px 2px #000; |
| | white-space: nowrap; |
| | transform: translateX(-50%); |
| | padding: 2px 6px; |
| | background: rgba(0,0,0,0.4); |
| | border-radius: 4px; |
| | } |
| | |
| | |
| | #reconnect-overlay { |
| | position: absolute; |
| | inset: 0; |
| | background: rgba(0,0,0,0.8); |
| | display: none; |
| | flex-direction: column; |
| | justify-content: center; |
| | align-items: center; |
| | z-index: 500; |
| | color: white; |
| | } |
| | |
| | #reconnect-overlay.visible { display: flex; } |
| | |
| | #reconnect-overlay h2 { |
| | margin-top: 20px; |
| | font-size: 24px; |
| | } |
| | |
| | #reconnect-overlay p { |
| | color: #888; |
| | margin-top: 10px; |
| | } |
| | </style> |
| | </head> |
| | <body> |
| |
|
| | <div id="game-container"> |
| | <canvas id="canvas"></canvas> |
| | <div id="labels-container"></div> |
| |
|
| | <div class="ui-layer"> |
| | <div id="score">0</div> |
| | <div id="player-count">Players: 0</div> |
| | <div id="tutorial">Swipe or Tap to Move</div> |
| | </div> |
| |
|
| | <div id="leaderboard"> |
| | <h3>🏆 Top 3</h3> |
| | <div id="lb-entries"></div> |
| | </div> |
| |
|
| | <div id="net-stats"> |
| | <div class="stat"> |
| | <div class="stat-dot good" id="conn-dot"></div> |
| | <span id="ping-display">--ms</span> |
| | </div> |
| | <div class="stat" id="players-online">0 online</div> |
| | </div> |
| |
|
| | <div id="game-over"> |
| | <h1>💀 SPLAT!</h1> |
| | <div id="final-score">Score: 0</div> |
| | <button class="btn-restart" id="restart-btn">Play Again</button> |
| | </div> |
| |
|
| | <div id="reconnect-overlay"> |
| | <div class="spinner" style="width:40px;height:40px;border-width:4px;"></div> |
| | <h2>Reconnecting...</h2> |
| | <p id="reconnect-info">Attempting to reconnect</p> |
| | </div> |
| |
|
| | <div id="login-screen"> |
| | <div class="login-content"> |
| | <div class="game-logo">🐧</div> |
| | <h1>VOXEL HOPPER</h1> |
| | <div class="subtitle">Multiplayer</div> |
| | |
| | <input type="text" id="username-input" placeholder="Enter your name" maxlength="12" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"> |
| | <button id="join-btn" disabled> |
| | <span id="join-btn-text">Connecting...</span> |
| | </button> |
| | |
| | <div class="connection-box connecting" id="conn-status"> |
| | <div class="connection-dot"></div> |
| | <span id="conn-text">Connecting to server...</span> |
| | </div> |
| | <div class="retry-info" id="retry-info"></div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| | <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
| | <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script> |
| |
|
| | <script> |
| | (function() { |
| | 'use strict'; |
| | |
| | |
| | const CONFIG = { |
| | TILE_SIZE: 10, |
| | GRID_WIDTH: 13, |
| | HOP_DURATION: 120, |
| | MOVE_COOLDOWN: 90, |
| | MAX_QUEUE: 2, |
| | INTERP_DELAY: 100, |
| | RECONNECT_DELAY: 1000, |
| | MAX_RECONNECT_DELAY: 10000, |
| | PING_INTERVAL: 3000 |
| | }; |
| | |
| | const COLORS = { |
| | sky: 0x87CEEB, |
| | grass: 0x71AA34, grassLight: 0x86D455, |
| | road: 0x222222, roadLine: 0xFFFFFF, |
| | water: 0x00BFFF, |
| | treeTrunk: 0x8B5A2B, treeLeaves: 0x4CAF50, |
| | penguin: 0x222222, belly: 0xFFFFFF, beak: 0xFFCC00, |
| | carRed: 0xE74C3C, carBlue: 0x3498DB, |
| | log: 0x5D4037, |
| | otherPlayer: 0xFF6B6B |
| | }; |
| | |
| | |
| | let socket = null; |
| | let myId = null; |
| | let serverTimeDiff = 0; |
| | let ping = 0; |
| | let lastPingTime = 0; |
| | let moveSeq = 0; |
| | let reconnectAttempts = 0; |
| | let isConnected = false; |
| | let hasJoined = false; |
| | |
| | let scene, camera, renderer; |
| | let player = null; |
| | let otherPlayers = new Map(); |
| | let lanes = new Map(); |
| | |
| | let playerGX = 0, playerGZ = 0; |
| | let playerWX = 0, playerWZ = 0; |
| | let score = 0; |
| | let maxScore = 0; |
| | let isGameOver = false; |
| | let isHopping = false; |
| | let hopStartTime = 0; |
| | let hopStartPos = { x: 0, z: 0 }; |
| | let hopTargetPos = { x: 0, z: 0 }; |
| | let attachedLogId = null; |
| | let lastMoveTime = 0; |
| | let moveQueue = []; |
| | |
| | |
| | let touchStartX = 0, touchStartY = 0; |
| | let touchStartTime = 0; |
| | |
| | |
| | const $canvas = document.getElementById('canvas'); |
| | const $login = document.getElementById('login-screen'); |
| | const $nameInput = document.getElementById('username-input'); |
| | const $joinBtn = document.getElementById('join-btn'); |
| | const $joinBtnText = document.getElementById('join-btn-text'); |
| | const $connStatus = document.getElementById('conn-status'); |
| | const $connText = document.getElementById('conn-text'); |
| | const $retryInfo = document.getElementById('retry-info'); |
| | const $reconnectOverlay = document.getElementById('reconnect-overlay'); |
| | const $reconnectInfo = document.getElementById('reconnect-info'); |
| | const $score = document.getElementById('score'); |
| | const $tutorial = document.getElementById('tutorial'); |
| | const $gameOver = document.getElementById('game-over'); |
| | const $finalScore = document.getElementById('final-score'); |
| | const $restartBtn = document.getElementById('restart-btn'); |
| | const $lbEntries = document.getElementById('lb-entries'); |
| | const $pingDisplay = document.getElementById('ping-display'); |
| | const $connDot = document.getElementById('conn-dot'); |
| | const $playersOnline = document.getElementById('players-online'); |
| | const $playerCount = document.getElementById('player-count'); |
| | const $labelsContainer = document.getElementById('labels-container'); |
| | |
| | |
| | function init() { |
| | initThree(); |
| | initSocket(); |
| | initInputs(); |
| | animate(); |
| | } |
| | |
| | function initThree() { |
| | scene = new THREE.Scene(); |
| | scene.background = new THREE.Color(COLORS.sky); |
| | scene.fog = new THREE.Fog(COLORS.sky, 160, 280); |
| | |
| | const aspect = window.innerWidth / window.innerHeight; |
| | const d = 100; |
| | camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000); |
| | camera.position.set(-100, 100, 100); |
| | camera.lookAt(0, 0, 0); |
| | |
| | renderer = new THREE.WebGLRenderer({ canvas: $canvas, antialias: true }); |
| | renderer.setSize(window.innerWidth, window.innerHeight); |
| | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| | renderer.shadowMap.enabled = true; |
| | renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| | |
| | |
| | scene.add(new THREE.AmbientLight(0xffffff, 0.75)); |
| | const dirLight = new THREE.DirectionalLight(0xffffff, 0.6); |
| | dirLight.position.set(-50, 100, 50); |
| | dirLight.castShadow = true; |
| | dirLight.shadow.mapSize.set(1024, 1024); |
| | const s = 100; |
| | dirLight.shadow.camera.left = -s; |
| | dirLight.shadow.camera.right = s; |
| | dirLight.shadow.camera.top = s; |
| | dirLight.shadow.camera.bottom = -s; |
| | scene.add(dirLight); |
| | |
| | window.addEventListener('resize', onResize); |
| | } |
| | |
| | |
| | function initSocket() { |
| | updateConnectionUI('connecting', 'Connecting to server...'); |
| | |
| | const socketOptions = { |
| | transports: ['websocket', 'polling'], |
| | upgrade: true, |
| | rememberUpgrade: true, |
| | timeout: 20000, |
| | reconnection: true, |
| | reconnectionDelay: CONFIG.RECONNECT_DELAY, |
| | reconnectionDelayMax: CONFIG.MAX_RECONNECT_DELAY, |
| | reconnectionAttempts: Infinity, |
| | forceNew: false |
| | }; |
| | |
| | socket = io(window.location.origin, socketOptions); |
| | |
| | |
| | socket.on('connect', onConnect); |
| | socket.on('disconnect', onDisconnect); |
| | socket.on('connect_error', onConnectError); |
| | socket.on('reconnect_attempt', onReconnectAttempt); |
| | socket.on('reconnect', onReconnect); |
| | |
| | |
| | socket.on('ack', onAck); |
| | socket.on('init', onInit); |
| | socket.on('pj', onPlayerJoin); |
| | socket.on('pl', onPlayerLeave); |
| | socket.on('pm', onPlayerMove); |
| | socket.on('pd', onPlayerDie); |
| | socket.on('prs', onPlayerRespawn); |
| | socket.on('mc', onMoveConfirm); |
| | socket.on('mr', onMoveReject); |
| | socket.on('gs', onGameState); |
| | socket.on('nl', onNewLane); |
| | socket.on('lb', onLeaderboard); |
| | socket.on('po', onPong); |
| | socket.on('rsd', onRespawned); |
| | socket.on('err', onError); |
| | |
| | |
| | setInterval(() => { |
| | if (socket && socket.connected) { |
| | socket.emit('hb'); |
| | } |
| | }, 5000); |
| | |
| | |
| | setInterval(() => { |
| | if (socket && socket.connected && hasJoined) { |
| | lastPingTime = Date.now(); |
| | socket.emit('p', lastPingTime); |
| | } |
| | }, CONFIG.PING_INTERVAL); |
| | } |
| | |
| | function onConnect() { |
| | console.log('Connected:', socket.id); |
| | isConnected = true; |
| | reconnectAttempts = 0; |
| | |
| | if (hasJoined) { |
| | $reconnectOverlay.classList.remove('visible'); |
| | |
| | const name = $nameInput.value.trim() || 'Anon'; |
| | socket.emit('join', { name }); |
| | } else { |
| | updateConnectionUI('connected', 'Connected!'); |
| | $joinBtn.disabled = false; |
| | $joinBtnText.textContent = 'Play'; |
| | } |
| | } |
| | |
| | function onDisconnect(reason) { |
| | console.log('Disconnected:', reason); |
| | isConnected = false; |
| | |
| | if (hasJoined) { |
| | $reconnectOverlay.classList.add('visible'); |
| | $reconnectInfo.textContent = 'Connection lost...'; |
| | } else { |
| | updateConnectionUI('error', 'Disconnected'); |
| | $joinBtn.disabled = true; |
| | $joinBtnText.textContent = 'Reconnecting...'; |
| | } |
| | } |
| | |
| | function onConnectError(err) { |
| | console.log('Connection error:', err.message); |
| | reconnectAttempts++; |
| | |
| | if (!hasJoined) { |
| | updateConnectionUI('error', 'Connection failed'); |
| | $retryInfo.textContent = `Retry ${reconnectAttempts}...`; |
| | $joinBtn.disabled = true; |
| | $joinBtnText.textContent = 'Reconnecting...'; |
| | } |
| | } |
| | |
| | function onReconnectAttempt(attempt) { |
| | console.log('Reconnect attempt:', attempt); |
| | reconnectAttempts = attempt; |
| | |
| | if (hasJoined) { |
| | $reconnectInfo.textContent = `Attempt ${attempt}...`; |
| | } else { |
| | $retryInfo.textContent = `Retry ${attempt}...`; |
| | } |
| | } |
| | |
| | function onReconnect() { |
| | console.log('Reconnected!'); |
| | reconnectAttempts = 0; |
| | $retryInfo.textContent = ''; |
| | } |
| | |
| | function updateConnectionUI(status, text) { |
| | $connStatus.className = 'connection-box ' + status; |
| | $connText.textContent = text; |
| | } |
| | |
| | |
| | function onAck(data) { |
| | myId = data.id; |
| | serverTimeDiff = Date.now() - data.t; |
| | console.log('Ack received, time diff:', serverTimeDiff); |
| | } |
| | |
| | function onInit(data) { |
| | console.log('Init received'); |
| | hasJoined = true; |
| | $login.style.display = 'none'; |
| | |
| | |
| | lanes.forEach(l => scene.remove(l.mesh)); |
| | lanes.clear(); |
| | otherPlayers.forEach(p => { |
| | scene.remove(p.mesh); |
| | removeLabel(p.id); |
| | }); |
| | otherPlayers.clear(); |
| | |
| | |
| | data.ls.forEach(l => createLane(l)); |
| | |
| | |
| | createPlayer(); |
| | resetPlayerState(data.p); |
| | |
| | |
| | data.ps.forEach(p => createOtherPlayer(p)); |
| | |
| | |
| | onLeaderboard(data.lb); |
| | updatePlayerCount(); |
| | |
| | serverTimeDiff = Date.now() - data.t; |
| | } |
| | |
| | function onPlayerJoin(data) { |
| | if (data.id !== myId) { |
| | createOtherPlayer(data); |
| | updatePlayerCount(); |
| | } |
| | } |
| | |
| | function onPlayerLeave(id) { |
| | const other = otherPlayers.get(id); |
| | if (other) { |
| | scene.remove(other.mesh); |
| | removeLabel(id); |
| | otherPlayers.delete(id); |
| | updatePlayerCount(); |
| | } |
| | } |
| | |
| | function onPlayerMove(data) { |
| | const other = otherPlayers.get(data.id); |
| | if (other) { |
| | const serverTime = data.t - serverTimeDiff; |
| | other.buffer.push({ |
| | t: serverTime, |
| | x: data.wx, |
| | z: data.wz, |
| | r: data.r, |
| | sx: data.hs.x, |
| | sz: data.hs.z, |
| | tx: data.ht.x, |
| | tz: data.ht.z |
| | }); |
| | |
| | while (other.buffer.length > 30) { |
| | other.buffer.shift(); |
| | } |
| | } |
| | } |
| | |
| | function onPlayerDie(data) { |
| | if (data.id === myId) { |
| | gameOver(data.type === 'water'); |
| | } else { |
| | const other = otherPlayers.get(data.id); |
| | if (other) { |
| | other.alive = false; |
| | if (data.type === 'water') { |
| | other.mesh.position.y = -10; |
| | } else { |
| | other.mesh.scale.set(1.5, 0.1, 1.5); |
| | } |
| | } |
| | } |
| | } |
| | |
| | function onPlayerRespawn(data) { |
| | const other = otherPlayers.get(data.id); |
| | if (other) { |
| | other.alive = true; |
| | other.mesh.position.set(0, 0, 0); |
| | other.mesh.scale.set(1, 1, 1); |
| | other.buffer = []; |
| | } |
| | } |
| | |
| | function onMoveConfirm(data) { |
| | if (data.sc > score) { |
| | score = data.sc; |
| | $score.textContent = score; |
| | } |
| | serverTimeDiff = Date.now() - data.t; |
| | } |
| | |
| | function onMoveReject(data) { |
| | console.log('Move rejected:', data.r); |
| | } |
| | |
| | function onGameState(data) { |
| | |
| | Object.entries(data.obs).forEach(([idx, obstacles]) => { |
| | const lane = lanes.get(parseInt(idx)); |
| | if (lane) { |
| | updateObstacles(lane, obstacles); |
| | } |
| | }); |
| | |
| | |
| | data.ps.forEach(p => { |
| | if (p.id !== myId) { |
| | const other = otherPlayers.get(p.id); |
| | if (other) { |
| | other.serverState = p; |
| | other.alive = p.a === 1; |
| | other.score = p.s; |
| | } |
| | } |
| | }); |
| | |
| | $playersOnline.textContent = data.ps.length + ' online'; |
| | serverTimeDiff = Date.now() - data.t; |
| | } |
| | |
| | function onNewLane(data) { |
| | createLane(data); |
| | } |
| | |
| | function onLeaderboard(data) { |
| | $lbEntries.innerHTML = ''; |
| | data.forEach((e, i) => { |
| | const div = document.createElement('div'); |
| | div.className = 'lb-entry ' + ['gold', 'silver', 'bronze'][i]; |
| | div.innerHTML = `<span class="lb-name">${escapeHtml(e.n)}</span><span>${e.s}</span>`; |
| | $lbEntries.appendChild(div); |
| | }); |
| | } |
| | |
| | function onPong(data) { |
| | ping = Date.now() - data.c; |
| | serverTimeDiff = Date.now() - data.s - ping / 2; |
| | |
| | $pingDisplay.textContent = ping + 'ms'; |
| | |
| | |
| | if (ping < 100) { |
| | $connDot.className = 'stat-dot good'; |
| | } else if (ping < 250) { |
| | $connDot.className = 'stat-dot medium'; |
| | } else { |
| | $connDot.className = 'stat-dot poor'; |
| | } |
| | } |
| | |
| | function onRespawned(data) { |
| | resetPlayerState(data); |
| | isGameOver = false; |
| | $gameOver.classList.remove('visible'); |
| | } |
| | |
| | function onError(data) { |
| | console.error('Server error:', data.m); |
| | } |
| | |
| | function escapeHtml(text) { |
| | const div = document.createElement('div'); |
| | div.textContent = text; |
| | return div.innerHTML; |
| | } |
| | |
| | |
| | function createVoxel(w, h, d, color, x = 0, y = 0, z = 0) { |
| | const geo = new THREE.BoxGeometry(w, h, d); |
| | const mat = new THREE.MeshLambertMaterial({ color }); |
| | const mesh = new THREE.Mesh(geo, mat); |
| | mesh.position.set(x, y, z); |
| | mesh.castShadow = true; |
| | mesh.receiveShadow = true; |
| | return mesh; |
| | } |
| | |
| | function createPlayerMesh(bodyColor = COLORS.penguin) { |
| | const group = new THREE.Group(); |
| | group.add(createVoxel(7, 9, 7, bodyColor, 0, 4.5, 0)); |
| | group.add(createVoxel(5, 6, 2, COLORS.belly, 0, 4, 3)); |
| | group.add(createVoxel(3, 2, 3, COLORS.beak, 0, 7.5, 3)); |
| | group.add(createVoxel(2.5, 2, 2.5, COLORS.beak, -2, 1, 1)); |
| | group.add(createVoxel(2.5, 2, 2.5, COLORS.beak, 2, 1, 1)); |
| | return group; |
| | } |
| | |
| | function createPlayer() { |
| | if (player) scene.remove(player); |
| | player = createPlayerMesh(); |
| | scene.add(player); |
| | } |
| | |
| | function createOtherPlayer(data) { |
| | if (otherPlayers.has(data.id)) return; |
| | |
| | const mesh = createPlayerMesh(COLORS.otherPlayer); |
| | mesh.position.set(data.wx, 0, data.wz); |
| | scene.add(mesh); |
| | |
| | otherPlayers.set(data.id, { |
| | id: data.id, |
| | name: data.n, |
| | mesh, |
| | buffer: [], |
| | alive: data.a === 1, |
| | score: data.s, |
| | lastX: data.wx, |
| | lastZ: data.wz |
| | }); |
| | |
| | createLabel(data.id, data.n); |
| | } |
| | |
| | function createLabel(id, name) { |
| | const label = document.createElement('div'); |
| | label.className = 'player-label'; |
| | label.id = 'label-' + id; |
| | label.textContent = name; |
| | $labelsContainer.appendChild(label); |
| | } |
| | |
| | function removeLabel(id) { |
| | const label = document.getElementById('label-' + id); |
| | if (label) label.remove(); |
| | } |
| | |
| | function createLane(data) { |
| | if (lanes.has(data.i)) return; |
| | |
| | const lane = { |
| | index: data.i, |
| | type: data.t, |
| | staticObs: data.so || [], |
| | speed: data.sp, |
| | dir: data.d, |
| | mesh: new THREE.Group(), |
| | obstacles: [], |
| | obsMap: new Map() |
| | }; |
| | |
| | const typeColors = { |
| | 'g': [data.i % 2 === 0 ? COLORS.grass : COLORS.grassLight, -5], |
| | 'r': [COLORS.road, -5], |
| | 'w': [COLORS.water, -13] |
| | }; |
| | |
| | const [color, yPos] = typeColors[data.t]; |
| | const ground = createVoxel(CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE + 200, CONFIG.TILE_SIZE, CONFIG.TILE_SIZE, color, 0, yPos, 0); |
| | lane.mesh.add(ground); |
| | |
| | if (data.t === 'r') { |
| | lane.mesh.add(createVoxel(CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE, 1, 2, COLORS.roadLine, 0, yPos + 0.6, 0)); |
| | } |
| | |
| | |
| | if (data.t === 'g' && lane.staticObs) { |
| | lane.staticObs.forEach(gx => { |
| | const tree = new THREE.Group(); |
| | tree.add(createVoxel(3, 5, 3, COLORS.treeTrunk, 0, 2.5, 0)); |
| | tree.add(createVoxel(9, 9, 9, COLORS.treeLeaves, 0, 7, 0)); |
| | tree.position.x = gx * CONFIG.TILE_SIZE; |
| | lane.mesh.add(tree); |
| | }); |
| | } |
| | |
| | lane.mesh.position.z = data.i * CONFIG.TILE_SIZE; |
| | scene.add(lane.mesh); |
| | lanes.set(data.i, lane); |
| | |
| | |
| | if (data.obs) { |
| | data.obs.forEach(o => addObstacle(lane, o)); |
| | } |
| | } |
| | |
| | function addObstacle(lane, data) { |
| | const mesh = new THREE.Group(); |
| | |
| | if (data.l) { |
| | mesh.add(createVoxel(data.w, 2, 7, COLORS.log, 0, -1, 0)); |
| | } else { |
| | const color = Math.random() > 0.5 ? COLORS.carRed : COLORS.carBlue; |
| | mesh.add(createVoxel(12, 7, 7, color, 0, 3.5, 0)); |
| | mesh.add(createVoxel(8, 3, 5, 0xFFFFFF, 0, 7, 0)); |
| | } |
| | |
| | mesh.position.x = data.x; |
| | lane.mesh.add(mesh); |
| | |
| | const obs = { id: data.id, mesh, isLog: data.l, width: data.w, x: data.x, targetX: data.x }; |
| | lane.obstacles.push(obs); |
| | lane.obsMap.set(data.id, obs); |
| | } |
| | |
| | function updateObstacles(lane, serverObs) { |
| | const serverIds = new Set(serverObs.map(o => o.id)); |
| | |
| | |
| | for (let i = lane.obstacles.length - 1; i >= 0; i--) { |
| | const obs = lane.obstacles[i]; |
| | if (!serverIds.has(obs.id)) { |
| | lane.mesh.remove(obs.mesh); |
| | lane.obsMap.delete(obs.id); |
| | lane.obstacles.splice(i, 1); |
| | } |
| | } |
| | |
| | |
| | serverObs.forEach(so => { |
| | let obs = lane.obsMap.get(so.id); |
| | if (obs) { |
| | obs.targetX = so.x; |
| | } else { |
| | |
| | } |
| | }); |
| | } |
| | |
| | |
| | function resetPlayerState(data) { |
| | playerGX = data.gx; |
| | playerGZ = data.gz; |
| | playerWX = data.wx; |
| | playerWZ = data.wz; |
| | score = data.s; |
| | maxScore = data.s; |
| | isGameOver = false; |
| | isHopping = false; |
| | attachedLogId = null; |
| | moveQueue = []; |
| | |
| | player.position.set(playerWX, 0, playerWZ); |
| | player.rotation.y = data.r || 0; |
| | player.scale.set(1, 1, 1); |
| | |
| | $score.textContent = score; |
| | $tutorial.style.display = 'block'; |
| | $gameOver.classList.remove('visible'); |
| | |
| | camera.position.set(playerWX - 100, 100, playerWZ + 100); |
| | } |
| | |
| | function updatePlayerCount() { |
| | const count = otherPlayers.size + 1; |
| | $playerCount.textContent = 'Players: ' + count; |
| | } |
| | |
| | |
| | function attemptMove(dx, dz) { |
| | if (isGameOver || !hasJoined) return; |
| | if (dz < 0) return; |
| | |
| | const now = Date.now(); |
| | |
| | |
| | if (now - lastMoveTime < CONFIG.MOVE_COOLDOWN) { |
| | if (moveQueue.length < CONFIG.MAX_QUEUE) { |
| | moveQueue.push({ dx, dz }); |
| | } |
| | return; |
| | } |
| | |
| | if (isHopping) { |
| | if (moveQueue.length < CONFIG.MAX_QUEUE) { |
| | moveQueue.push({ dx, dz }); |
| | } |
| | return; |
| | } |
| | |
| | const tx = playerGX + dx; |
| | const tz = playerGZ + dz; |
| | |
| | |
| | if (Math.abs(tx) > Math.floor(CONFIG.GRID_WIDTH / 2)) return; |
| | |
| | |
| | const lane = lanes.get(tz); |
| | if (lane && lane.type === 'g' && lane.staticObs.includes(tx)) return; |
| | |
| | |
| | lastMoveTime = now; |
| | isHopping = true; |
| | hopStartTime = now; |
| | hopStartPos = { x: playerWX, z: playerWZ }; |
| | hopTargetPos = { x: tx * CONFIG.TILE_SIZE, z: tz * CONFIG.TILE_SIZE }; |
| | playerGX = tx; |
| | playerGZ = tz; |
| | playerWX = tx * CONFIG.TILE_SIZE; |
| | playerWZ = tz * CONFIG.TILE_SIZE; |
| | attachedLogId = null; |
| | |
| | |
| | if (dx === 1) player.rotation.y = -Math.PI / 2; |
| | else if (dx === -1) player.rotation.y = Math.PI / 2; |
| | else if (dz === 1) player.rotation.y = 0; |
| | else if (dz === -1) player.rotation.y = Math.PI; |
| | |
| | |
| | if (tz > maxScore) { |
| | maxScore = tz; |
| | score = maxScore; |
| | $score.textContent = score; |
| | } |
| | |
| | |
| | socket.emit('m', { dx, dz, s: ++moveSeq }); |
| | |
| | $tutorial.style.display = 'none'; |
| | } |
| | |
| | function processHop() { |
| | if (!isHopping) return; |
| | |
| | const now = Date.now(); |
| | const elapsed = now - hopStartTime; |
| | const t = Math.min(elapsed / CONFIG.HOP_DURATION, 1); |
| | |
| | player.position.x = hopStartPos.x + (hopTargetPos.x - hopStartPos.x) * t; |
| | player.position.z = hopStartPos.z + (hopTargetPos.z - hopStartPos.z) * t; |
| | player.position.y = Math.sin(t * Math.PI) * 9; |
| | |
| | |
| | player.scale.set(t < 1 ? 0.9 : 1, t < 1 ? 1.1 : 1, t < 1 ? 0.9 : 1); |
| | |
| | |
| | checkMidHopCollision(t); |
| | |
| | if (t >= 1) { |
| | isHopping = false; |
| | player.position.set(hopTargetPos.x, 0, hopTargetPos.z); |
| | player.scale.set(1.3, 0.7, 1.3); |
| | setTimeout(() => player && player.scale.set(1, 1, 1), 60); |
| | |
| | checkLandingCollision(); |
| | |
| | |
| | if (moveQueue.length > 0 && !isGameOver) { |
| | const next = moveQueue.shift(); |
| | setTimeout(() => attemptMove(next.dx, next.dz), 10); |
| | } |
| | } |
| | } |
| | |
| | function checkMidHopCollision(t) { |
| | if (isGameOver) return; |
| | |
| | const cx = hopStartPos.x + (hopTargetPos.x - hopStartPos.x) * t; |
| | const cz = hopStartPos.z + (hopTargetPos.z - hopStartPos.z) * t; |
| | const cgz = Math.round(cz / CONFIG.TILE_SIZE); |
| | |
| | const lane = lanes.get(cgz); |
| | if (!lane || lane.type !== 'r') return; |
| | |
| | for (const obs of lane.obstacles) { |
| | if (obs.isLog) continue; |
| | if (Math.abs(cx - obs.mesh.position.x) < (obs.width / 2 + 3)) { |
| | gameOver(false); |
| | return; |
| | } |
| | } |
| | } |
| | |
| | function checkLandingCollision() { |
| | if (isGameOver) return; |
| | |
| | const lane = lanes.get(playerGZ); |
| | if (!lane || lane.type === 'g') return; |
| | |
| | |
| | if (Math.abs(player.position.x) > (CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE / 2 + 10)) { |
| | gameOver(false); |
| | return; |
| | } |
| | |
| | let onLog = false; |
| | |
| | for (const obs of lane.obstacles) { |
| | const ox = obs.mesh.position.x; |
| | |
| | if (obs.isLog) { |
| | if (Math.abs(player.position.x - ox) < (obs.width / 2 + 4)) { |
| | onLog = true; |
| | attachedLogId = obs.id; |
| | } |
| | } else { |
| | if (Math.abs(player.position.x - ox) < (obs.width / 2 + 3)) { |
| | gameOver(false); |
| | return; |
| | } |
| | } |
| | } |
| | |
| | if (lane.type === 'w' && !onLog) { |
| | gameOver(true); |
| | } else if (lane.type !== 'w') { |
| | attachedLogId = null; |
| | } |
| | } |
| | |
| | function gameOver(drown = false) { |
| | if (isGameOver) return; |
| | isGameOver = true; |
| | isHopping = false; |
| | moveQueue = []; |
| | |
| | if (drown) { |
| | player.position.y = -10; |
| | } else { |
| | player.scale.set(1.5, 0.1, 1.5); |
| | } |
| | |
| | $finalScore.textContent = 'Score: ' + score; |
| | $gameOver.classList.add('visible'); |
| | } |
| | |
| | function respawn() { |
| | if (!socket || !socket.connected) return; |
| | socket.emit('rs'); |
| | } |
| | |
| | |
| | function updateObstaclePositions() { |
| | lanes.forEach(lane => { |
| | lane.obstacles.forEach(obs => { |
| | if (obs.targetX !== undefined) { |
| | const diff = obs.targetX - obs.mesh.position.x; |
| | obs.mesh.position.x += diff * 0.2; |
| | obs.x = obs.mesh.position.x; |
| | } |
| | }); |
| | }); |
| | } |
| | |
| | function updateLogRiding() { |
| | if (isHopping || attachedLogId === null || isGameOver) return; |
| | |
| | const lane = lanes.get(playerGZ); |
| | if (!lane) return; |
| | |
| | const log = lane.obstacles.find(o => o.id === attachedLogId); |
| | if (log) { |
| | player.position.x = log.mesh.position.x; |
| | playerWX = player.position.x; |
| | playerGX = Math.round(playerWX / CONFIG.TILE_SIZE); |
| | |
| | if (Math.abs(playerWX) > (CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE / 2 + 10)) { |
| | gameOver(true); |
| | } |
| | } else { |
| | attachedLogId = null; |
| | if (lane.type === 'w') { |
| | checkLandingCollision(); |
| | } |
| | } |
| | } |
| | |
| | function updateOtherPlayers() { |
| | const renderTime = Date.now() - CONFIG.INTERP_DELAY; |
| | |
| | otherPlayers.forEach(other => { |
| | if (!other.alive) return; |
| | |
| | const buffer = other.buffer; |
| | |
| | if (buffer.length >= 2) { |
| | let i = 0; |
| | while (i < buffer.length - 1 && buffer[i + 1].t <= renderTime) i++; |
| | |
| | if (i < buffer.length - 1) { |
| | const p1 = buffer[i]; |
| | const p2 = buffer[i + 1]; |
| | const t = Math.max(0, Math.min(1, (renderTime - p1.t) / (p2.t - p1.t))); |
| | |
| | |
| | other.mesh.position.x = p1.x + (p2.x - p1.x) * t; |
| | other.mesh.position.z = p1.z + (p2.z - p1.z) * t; |
| | other.mesh.position.y = Math.sin(t * Math.PI) * 9; |
| | other.mesh.rotation.y = p2.r; |
| | other.mesh.scale.set(t < 1 ? 0.9 : 1, t < 1 ? 1.1 : 1, t < 1 ? 0.9 : 1); |
| | } else if (buffer.length > 0) { |
| | const last = buffer[buffer.length - 1]; |
| | other.mesh.position.set(last.x, 0, last.z); |
| | other.mesh.rotation.y = last.r; |
| | other.mesh.scale.set(1, 1, 1); |
| | } |
| | |
| | while (buffer.length > 2 && buffer[1].t < renderTime) buffer.shift(); |
| | } |
| | }); |
| | } |
| | |
| | function updateLabels() { |
| | otherPlayers.forEach((other, id) => { |
| | const label = document.getElementById('label-' + id); |
| | if (!label) return; |
| | |
| | if (!other.alive) { |
| | label.style.display = 'none'; |
| | return; |
| | } |
| | |
| | const pos = other.mesh.position.clone(); |
| | pos.y += 18; |
| | |
| | const vector = pos.project(camera); |
| | |
| | if (vector.z < 1 && vector.z > -1) { |
| | const x = (vector.x * 0.5 + 0.5) * window.innerWidth; |
| | const y = (-vector.y * 0.5 + 0.5) * window.innerHeight; |
| | label.style.left = x + 'px'; |
| | label.style.top = (y - 15) + 'px'; |
| | label.style.display = 'block'; |
| | } else { |
| | label.style.display = 'none'; |
| | } |
| | }); |
| | } |
| | |
| | function updateCamera() { |
| | if (isGameOver || !player) return; |
| | |
| | const tx = player.position.x - 100; |
| | const tz = player.position.z + 100; |
| | camera.position.x += (tx - camera.position.x) * 0.05; |
| | camera.position.z += (tz - camera.position.z) * 0.1; |
| | } |
| | |
| | |
| | function animate() { |
| | requestAnimationFrame(animate); |
| | |
| | processHop(); |
| | updateObstaclePositions(); |
| | updateLogRiding(); |
| | updateOtherPlayers(); |
| | updateCamera(); |
| | updateLabels(); |
| | |
| | renderer.render(scene, camera); |
| | } |
| | |
| | function onResize() { |
| | const aspect = window.innerWidth / window.innerHeight; |
| | const d = 100; |
| | camera.left = -d * aspect; |
| | camera.right = d * aspect; |
| | camera.top = d; |
| | camera.bottom = -d; |
| | camera.updateProjectionMatrix(); |
| | renderer.setSize(window.innerWidth, window.innerHeight); |
| | } |
| | |
| | |
| | function initInputs() { |
| | const container = document.getElementById('game-container'); |
| | |
| | |
| | container.addEventListener('touchstart', e => { |
| | if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; |
| | touchStartX = e.touches[0].clientX; |
| | touchStartY = e.touches[0].clientY; |
| | touchStartTime = Date.now(); |
| | }, { passive: true }); |
| | |
| | container.addEventListener('touchend', e => { |
| | if (isGameOver || !hasJoined) return; |
| | if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; |
| | e.preventDefault(); |
| | |
| | const dx = e.changedTouches[0].clientX - touchStartX; |
| | const dy = e.changedTouches[0].clientY - touchStartY; |
| | handleSwipe(dx, dy); |
| | }, { passive: false }); |
| | |
| | container.addEventListener('touchmove', e => { |
| | if (e.target.tagName !== 'INPUT') e.preventDefault(); |
| | }, { passive: false }); |
| | |
| | |
| | let mouseX, mouseY; |
| | container.addEventListener('mousedown', e => { |
| | if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; |
| | mouseX = e.clientX; |
| | mouseY = e.clientY; |
| | }); |
| | |
| | container.addEventListener('mouseup', e => { |
| | if (isGameOver || !hasJoined) return; |
| | if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; |
| | handleSwipe(e.clientX - mouseX, e.clientY - mouseY); |
| | }); |
| | |
| | |
| | document.addEventListener('keydown', e => { |
| | if (e.target.tagName === 'INPUT') return; |
| | |
| | switch(e.key) { |
| | case 'ArrowUp': case 'w': case 'W': attemptMove(0, 1); break; |
| | case 'ArrowDown': case 's': case 'S': attemptMove(0, -1); break; |
| | case 'ArrowLeft': case 'a': case 'A': attemptMove(1, 0); break; |
| | case 'ArrowRight': case 'd': case 'D': attemptMove(-1, 0); break; |
| | } |
| | }); |
| | |
| | |
| | $nameInput.addEventListener('input', () => { |
| | $joinBtn.disabled = !$nameInput.value.trim() || !isConnected; |
| | }); |
| | |
| | $nameInput.addEventListener('keypress', e => { |
| | if (e.key === 'Enter' && !$joinBtn.disabled) { |
| | joinGame(); |
| | } |
| | }); |
| | |
| | $joinBtn.addEventListener('click', joinGame); |
| | $restartBtn.addEventListener('click', respawn); |
| | } |
| | |
| | function handleSwipe(dx, dy) { |
| | if (Math.abs(dx) < 10 && Math.abs(dy) < 10) { |
| | attemptMove(0, 1); |
| | return; |
| | } |
| | |
| | if (Math.abs(dx) > Math.abs(dy)) { |
| | attemptMove(dx > 0 ? 1 : -1, 0); |
| | } else { |
| | attemptMove(0, dy < 0 ? 1 : -1); |
| | } |
| | } |
| | |
| | function joinGame() { |
| | const name = $nameInput.value.trim(); |
| | if (!name || !socket || !socket.connected) return; |
| | |
| | $joinBtn.disabled = true; |
| | $joinBtnText.innerHTML = '<span class="spinner" style="width:16px;height:16px;display:inline-block;vertical-align:middle;"></span>'; |
| | |
| | socket.emit('join', { name }); |
| | } |
| | |
| | |
| | init(); |
| | })(); |
| | </script> |
| | </body> |
| | </html> |