yt / public /index.html
OrbitMC's picture
Update public/index.html
7274766 verified
<!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 ===== */
#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 Status Indicator */
.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;
}
/* Loading Spinner */
.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); } }
/* ===== GAME UI ===== */
.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 - Safe area for Dynamic Island */
#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;
}
/* Network Stats */
#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 */
#player-count {
position: absolute;
top: 130px;
left: 20px;
color: rgba(255,255,255,0.6);
font-size: 12px;
}
/* Tutorial */
#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 */
#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;
}
/* Player Labels */
#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;
}
/* Reconnecting Overlay */
#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';
// ============= CONFIGURATION =============
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
};
// ============= STATE =============
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 = [];
// Touch
let touchStartX = 0, touchStartY = 0;
let touchStartTime = 0;
// ============= DOM ELEMENTS =============
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');
// ============= INITIALIZATION =============
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;
// Lights
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);
}
// ============= SOCKET MANAGEMENT =============
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);
// Connection events
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
socket.on('connect_error', onConnectError);
socket.on('reconnect_attempt', onReconnectAttempt);
socket.on('reconnect', onReconnect);
// Game events
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);
// Heartbeat
setInterval(() => {
if (socket && socket.connected) {
socket.emit('hb');
}
}, 5000);
// Ping
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');
// Re-join with same name
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;
}
// ============= GAME EVENTS =============
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';
// Clear existing
lanes.forEach(l => scene.remove(l.mesh));
lanes.clear();
otherPlayers.forEach(p => {
scene.remove(p.mesh);
removeLabel(p.id);
});
otherPlayers.clear();
// Create lanes
data.ls.forEach(l => createLane(l));
// Create player
createPlayer();
resetPlayerState(data.p);
// Create other players
data.ps.forEach(p => createOtherPlayer(p));
// Update UI
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
});
// Trim buffer
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) {
// Update obstacles
Object.entries(data.obs).forEach(([idx, obstacles]) => {
const lane = lanes.get(parseInt(idx));
if (lane) {
updateObstacles(lane, obstacles);
}
});
// Update other players
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';
// Update connection quality indicator
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;
}
// ============= GAME OBJECTS =============
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));
}
// Trees
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);
// Create obstacles
if (data.obs) {
data.obs.forEach(o => addObstacle(lane, o));
}
}
function addObstacle(lane, data) {
const mesh = new THREE.Group();
if (data.l) { // Log
mesh.add(createVoxel(data.w, 2, 7, COLORS.log, 0, -1, 0));
} else { // Car
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));
// Remove old
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);
}
}
// Update/add
serverObs.forEach(so => {
let obs = lane.obsMap.get(so.id);
if (obs) {
obs.targetX = so.x;
} else {
// Need full data from a different source
}
});
}
// ============= PLAYER STATE =============
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;
}
// ============= MOVEMENT =============
function attemptMove(dx, dz) {
if (isGameOver || !hasJoined) return;
if (dz < 0) return; // No backward
const now = Date.now();
// Rate limit
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;
// Bounds check
if (Math.abs(tx) > Math.floor(CONFIG.GRID_WIDTH / 2)) return;
// Tree check
const lane = lanes.get(tz);
if (lane && lane.type === 'g' && lane.staticObs.includes(tx)) return;
// Execute locally
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;
// Rotation
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;
// Score
if (tz > maxScore) {
maxScore = tz;
score = maxScore;
$score.textContent = score;
}
// Send to server
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;
// Squash/stretch
player.scale.set(t < 1 ? 0.9 : 1, t < 1 ? 1.1 : 1, t < 1 ? 0.9 : 1);
// Mid-hop collision
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();
// Process queue
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;
// Bounds
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');
}
// ============= UPDATE LOOP =============
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)));
// Hop interpolation
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;
}
// ============= RENDER LOOP =============
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);
}
// ============= INPUT =============
function initInputs() {
const container = document.getElementById('game-container');
// Touch
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 });
// Mouse
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);
});
// Keyboard
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;
}
});
// Login
$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 });
}
// Start
init();
})();
</script>
</body>
</html>