OrbitMC commited on
Commit
7274766
·
verified ·
1 Parent(s): f75a3f2

Update public/index.html

Browse files
Files changed (1) hide show
  1. public/index.html +942 -767
public/index.html CHANGED
@@ -5,270 +5,384 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
  <title>Voxel Hopper: Multiplayer</title>
7
  <style>
8
- * { box-sizing: border-box; }
 
9
  body, html {
10
- margin: 0;
11
- padding: 0;
12
  width: 100%;
13
  height: 100%;
14
- background-color: #222;
15
  overflow: hidden;
16
- font-family: 'Courier New', Courier, monospace;
17
  touch-action: none;
18
  user-select: none;
19
  -webkit-user-select: none;
 
20
  }
21
 
22
  #game-container {
23
  position: relative;
24
  width: 100%;
25
  height: 100%;
26
- background-color: #87CEEB;
27
  }
28
 
29
- canvas {
30
- display: block;
31
- width: 100%;
32
- height: 100%;
33
- outline: none;
34
- }
35
 
36
- /* LOGIN SCREEN */
37
  #login-screen {
38
  position: absolute;
39
- top: 0;
40
- left: 0;
41
- width: 100%;
42
- height: 100%;
43
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
44
  display: flex;
45
  flex-direction: column;
46
  justify-content: center;
47
  align-items: center;
48
- z-index: 200;
 
49
  }
50
 
51
- #login-screen h1 {
52
- color: #fff;
53
- font-size: 48px;
 
 
 
 
 
54
  margin-bottom: 10px;
55
- text-shadow: 3px 3px 0 #000;
56
  }
57
 
58
- #login-screen p {
59
- color: #aaa;
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  margin-bottom: 30px;
 
 
61
  }
62
 
63
  #username-input {
64
- padding: 15px 25px;
65
- font-size: 20px;
66
- border: none;
67
- border-radius: 10px;
68
- width: 280px;
 
69
  text-align: center;
70
  font-family: inherit;
71
- margin-bottom: 20px;
 
 
 
 
 
72
  }
73
 
74
  #join-btn {
75
- padding: 15px 50px;
76
- font-size: 20px;
 
 
77
  font-weight: bold;
78
- background: #4CAF50;
79
  color: white;
80
  border: none;
81
- border-radius: 10px;
82
  cursor: pointer;
83
- box-shadow: 0 5px 0 #388E3C;
84
- transition: transform 0.1s;
 
 
85
  }
86
 
87
- #join-btn:active {
88
- transform: translateY(3px);
89
- box-shadow: 0 2px 0 #388E3C;
90
  }
91
 
92
  #join-btn:disabled {
93
- background: #666;
94
- box-shadow: 0 5px 0 #444;
 
95
  }
96
 
97
- /* CONNECTION STATUS */
98
- #connection-status {
99
- position: absolute;
100
- top: 10px;
101
- left: 50%;
102
- transform: translateX(-50%);
103
- padding: 8px 16px;
104
- background: rgba(0,0,0,0.7);
105
- color: #fff;
106
- border-radius: 20px;
107
- font-size: 12px;
108
- z-index: 150;
109
- display: none;
 
 
 
 
 
 
 
 
110
  }
111
 
112
- #connection-status.connected { background: rgba(76,175,80,0.8); }
113
- #connection-status.disconnected { background: rgba(244,67,54,0.8); display: block; }
114
- #connection-status.connecting { background: rgba(255,152,0,0.8); display: block; }
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- /* UI LAYER */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  .ui-layer {
118
  position: absolute;
119
- top: 0;
120
- left: 0;
121
- width: 100%;
122
- height: 100%;
123
  pointer-events: none;
124
- display: flex;
125
- flex-direction: column;
126
- justify-content: space-between;
127
  }
128
 
129
- .top-ui {
130
- padding-top: 60px;
131
- padding-left: 20px;
132
- display: flex;
133
- justify-content: space-between;
134
- align-items: flex-start;
135
- }
136
-
137
  #score {
138
- font-size: 80px;
 
 
 
139
  font-weight: 900;
140
  color: white;
141
- text-shadow: 4px 4px 0 rgba(0,0,0,0.2);
142
- margin: 0;
143
  line-height: 1;
144
  }
145
 
146
- /* LEADERBOARD - positioned for Dynamic Island */
147
  #leaderboard {
148
  position: absolute;
149
- top: 60px;
150
- right: 15px;
151
- background: rgba(0,0,0,0.6);
152
- padding: 12px 15px;
153
- border-radius: 12px;
154
- min-width: 140px;
155
  backdrop-filter: blur(10px);
 
156
  }
157
 
158
  #leaderboard h3 {
159
- margin: 0 0 8px 0;
160
  color: #FFD700;
161
- font-size: 14px;
 
162
  text-transform: uppercase;
 
163
  }
164
 
165
- .leaderboard-entry {
166
  display: flex;
167
  justify-content: space-between;
168
  color: white;
169
- font-size: 13px;
170
- padding: 3px 0;
171
  }
172
 
173
- .leaderboard-entry.gold { color: #FFD700; }
174
- .leaderboard-entry.silver { color: #C0C0C0; }
175
- .leaderboard-entry.bronze { color: #CD7F32; }
176
 
177
- .leaderboard-name {
178
- max-width: 90px;
179
  overflow: hidden;
180
  text-overflow: ellipsis;
181
  white-space: nowrap;
182
  }
183
 
184
- /* PING DISPLAY */
185
- #ping-display {
186
  position: absolute;
187
- bottom: 10px;
188
- left: 10px;
189
- color: rgba(255,255,255,0.6);
190
- font-size: 11px;
 
 
 
 
 
 
 
 
 
191
  }
 
 
 
192
 
193
- /* PLAYER COUNT */
194
  #player-count {
195
  position: absolute;
196
- top: 60px;
197
  left: 20px;
198
- color: rgba(255,255,255,0.7);
199
  font-size: 12px;
200
  }
201
 
202
- .bottom-ui { padding-bottom: 100px; text-align: center; }
203
-
204
  #tutorial {
 
 
 
 
205
  color: white;
206
  font-weight: bold;
207
- font-size: 24px;
208
  text-transform: uppercase;
209
  text-shadow: 2px 2px 0 rgba(0,0,0,0.3);
210
  animation: pulse 1.5s infinite;
211
  }
212
 
 
 
 
 
 
 
213
  #game-over {
214
  position: absolute;
215
- top: 0; left: 0; width: 100%; height: 100%;
216
- background: rgba(0,0,0,0.6);
217
  display: flex;
218
  flex-direction: column;
219
  justify-content: center;
220
  align-items: center;
221
  opacity: 0;
222
  pointer-events: none;
223
- transition: opacity 0.2s ease;
224
  z-index: 100;
225
  }
226
 
227
- #game-over.visible { opacity: 1; pointer-events: auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
  .btn-restart {
230
  background: #fff;
231
  color: #333;
232
  border: none;
233
- padding: 20px 50px;
234
- font-size: 24px;
235
  font-weight: 900;
236
  text-transform: uppercase;
237
  border-radius: 12px;
238
- box-shadow: 0 8px 0 #999;
239
  cursor: pointer;
240
- margin-top: 30px;
241
  transition: transform 0.1s;
242
- pointer-events: auto;
243
  }
244
- .btn-restart:active { transform: translateY(4px); box-shadow: 0 4px 0 #999; }
245
 
246
- @keyframes pulse {
247
- 0% { opacity: 0.7; }
248
- 50% { opacity: 1; transform: scale(1.05); }
249
- 100% { opacity: 0.7; }
 
 
 
 
 
 
 
250
  }
251
 
252
- /* Username labels */
253
  .player-label {
254
  position: absolute;
255
  color: white;
256
- font-size: 12px;
257
  font-weight: bold;
258
- text-shadow: 1px 1px 2px #000;
259
- pointer-events: none;
260
  white-space: nowrap;
261
  transform: translateX(-50%);
 
 
 
262
  }
263
 
264
- #labels-container {
 
265
  position: absolute;
266
- top: 0;
267
- left: 0;
268
- width: 100%;
269
- height: 100%;
270
- pointer-events: none;
271
- overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
  </style>
274
  </head>
@@ -276,298 +390,390 @@
276
 
277
  <div id="game-container">
278
  <canvas id="canvas"></canvas>
279
-
280
  <div id="labels-container"></div>
281
 
282
  <div class="ui-layer">
283
- <div class="top-ui">
284
- <div id="score">0</div>
285
- </div>
286
- <div class="bottom-ui">
287
- <div id="tutorial">Swipe or Tap</div>
288
- </div>
289
  </div>
290
 
291
  <div id="leaderboard">
292
  <h3>🏆 Top 3</h3>
293
- <div id="leaderboard-entries"></div>
294
  </div>
295
 
296
- <div id="player-count">Players: 0</div>
297
- <div id="ping-display">Ping: --ms</div>
298
-
299
- <div id="connection-status">Connecting...</div>
 
 
 
300
 
301
  <div id="game-over">
302
- <h1 style="color:white; font-size:60px; text-shadow:4px 4px 0 #000; margin:0;">SPLAT!</h1>
303
- <button class="btn-restart" id="restart-btn">Try Again</button>
 
 
 
 
 
 
 
304
  </div>
305
 
306
  <div id="login-screen">
307
- <h1>🐧 VOXEL HOPPER</h1>
308
- <p>Multiplayer Edition</p>
309
- <input type="text" id="username-input" placeholder="Enter your name" maxlength="15" autocomplete="off">
310
- <button id="join-btn" disabled>Join Game</button>
 
 
 
 
 
 
 
 
 
 
 
 
311
  </div>
312
  </div>
313
 
314
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
315
- <script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
316
 
317
  <script>
318
- // --- CONFIG ---
319
- const TILE_SIZE = 10;
320
- const GRID_WIDTH = 13;
321
- const HOP_DURATION = 120; // ms
322
- const INTERPOLATION_DELAY = 100; // ms for smooth other player movement
 
 
 
 
 
 
 
 
 
 
323
 
324
- // --- COLORS ---
325
  const COLORS = {
326
- sky: 0x87CEEB,
327
  grass: 0x71AA34, grassLight: 0x86D455,
328
- road: 0x222222,
329
- roadLine: 0xFFFFFF,
330
  water: 0x00BFFF,
331
  treeTrunk: 0x8B5A2B, treeLeaves: 0x4CAF50,
332
- penguinBody: 0x222222, penguinBelly: 0xFFFFFF, penguinBeak: 0xFFCC00,
333
  carRed: 0xE74C3C, carBlue: 0x3498DB,
334
  log: 0x5D4037,
335
  otherPlayer: 0xFF6B6B
336
  };
337
 
338
- // --- NETWORK ---
339
- let socket;
340
- let myPlayerId = null;
341
- let serverTimeOffset = 0;
342
  let ping = 0;
343
  let lastPingTime = 0;
344
- let moveSequence = 0;
345
- let pendingMoves = [];
 
 
346
 
347
- // --- GLOBALS ---
348
  let scene, camera, renderer;
349
- let player;
350
  let otherPlayers = new Map();
351
  let lanes = new Map();
 
 
 
352
  let score = 0;
 
353
  let isGameOver = false;
354
  let isHopping = false;
355
- let canMove = true;
356
- let lastMoveTime = 0;
357
- const MOVE_COOLDOWN = 100; // Minimum ms between moves (anti-spam)
358
-
359
- // Physics
360
- let playerGridX = 0;
361
- let playerGridZ = 0;
362
- let currentLaneIndex = 0;
363
- let maxLaneIndex = 0;
364
-
365
- let attachedLog = null;
366
- let attachedLogOffset = 0;
367
-
368
- // Animation
369
  let hopStartTime = 0;
370
- let hopStartPos = new THREE.Vector3();
371
- let hopTargetPos = new THREE.Vector3();
372
-
373
- // Input Buffer (limited queue for smoother play)
374
  let moveQueue = [];
375
- const MAX_QUEUE_SIZE = 2;
376
 
377
- // Input State
378
- let touchStartX = 0;
379
- let touchStartY = 0;
380
-
381
- // --- INITIALIZATION ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  function init() {
 
 
 
 
 
 
 
383
  scene = new THREE.Scene();
384
  scene.background = new THREE.Color(COLORS.sky);
385
  scene.fog = new THREE.Fog(COLORS.sky, 160, 280);
386
 
387
  const aspect = window.innerWidth / window.innerHeight;
388
- const d = 100;
389
  camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
390
- camera.position.set(-100, 100, 100);
391
  camera.lookAt(0, 0, 0);
392
 
393
- const canvas = document.getElementById('canvas');
394
- renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
395
  renderer.setSize(window.innerWidth, window.innerHeight);
 
396
  renderer.shadowMap.enabled = true;
397
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
398
 
399
  // Lights
400
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
401
- scene.add(ambientLight);
402
  const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
403
  dirLight.position.set(-50, 100, 50);
404
  dirLight.castShadow = true;
405
- dirLight.shadow.mapSize.width = 1024;
406
- dirLight.shadow.mapSize.height = 1024;
407
- const side = 100;
408
- dirLight.shadow.camera.left = -side;
409
- dirLight.shadow.camera.right = side;
410
- dirLight.shadow.camera.top = side;
411
- dirLight.shadow.camera.bottom = -side;
412
  scene.add(dirLight);
413
 
414
  window.addEventListener('resize', onResize);
415
- setupInputs();
416
- setupLogin();
417
-
418
- requestAnimationFrame(animate);
419
  }
420
 
421
- function setupLogin() {
422
- const loginScreen = document.getElementById('login-screen');
423
- const usernameInput = document.getElementById('username-input');
424
- const joinBtn = document.getElementById('join-btn');
425
- const connectionStatus = document.getElementById('connection-status');
426
 
427
- // Connect to server
428
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
429
- socket = io(window.location.origin, {
430
  transports: ['websocket', 'polling'],
 
 
 
431
  reconnection: true,
432
- reconnectionDelay: 1000,
433
- reconnectionAttempts: 10
434
- });
435
-
436
- socket.on('connect', () => {
437
- console.log('Connected to server');
438
- connectionStatus.className = 'connected';
439
- connectionStatus.textContent = 'Connected';
440
- joinBtn.disabled = false;
441
- setTimeout(() => { connectionStatus.style.display = 'none'; }, 1000);
442
- });
443
 
444
- socket.on('disconnect', () => {
445
- console.log('Disconnected from server');
446
- connectionStatus.className = 'disconnected';
447
- connectionStatus.textContent = 'Disconnected - Reconnecting...';
448
- connectionStatus.style.display = 'block';
449
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
- socket.on('connect_error', () => {
452
- connectionStatus.className = 'connecting';
453
- connectionStatus.textContent = 'Connection error - Retrying...';
454
- });
 
 
 
 
455
 
456
- usernameInput.addEventListener('input', () => {
457
- joinBtn.disabled = usernameInput.value.trim().length === 0 || !socket.connected;
458
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
459
 
460
- usernameInput.addEventListener('keypress', (e) => {
461
- if (e.key === 'Enter' && !joinBtn.disabled) {
462
- joinGame();
463
- }
464
- });
 
 
 
 
 
 
 
 
465
 
466
- joinBtn.addEventListener('click', joinGame);
 
 
 
 
 
 
 
 
 
 
467
 
468
- function joinGame() {
469
- const username = usernameInput.value.trim();
470
- if (username && socket.connected) {
471
- socket.emit('join', username);
472
- loginScreen.style.display = 'none';
473
- }
 
 
474
  }
 
475
 
476
- // Socket event handlers
477
- socket.on('init', handleInit);
478
- socket.on('playerJoined', handlePlayerJoined);
479
- socket.on('playerLeft', handlePlayerLeft);
480
- socket.on('playerMoved', handlePlayerMoved);
481
- socket.on('playerDied', handlePlayerDied);
482
- socket.on('playerRespawned', handlePlayerRespawned);
483
- socket.on('moveConfirmed', handleMoveConfirmed);
484
- socket.on('moveRejected', handleMoveRejected);
485
- socket.on('gameState', handleGameState);
486
- socket.on('newLane', handleNewLane);
487
- socket.on('leaderboard', handleLeaderboard);
488
- socket.on('pong', handlePong);
489
-
490
- // Ping loop
491
- setInterval(() => {
492
- if (socket.connected) {
493
- lastPingTime = Date.now();
494
- socket.emit('ping', lastPingTime);
495
- }
496
- }, 2000);
497
  }
498
 
499
- // --- NETWORK HANDLERS ---
500
- function handleInit(data) {
501
- myPlayerId = data.playerId;
502
- serverTimeOffset = Date.now() - data.serverTime;
503
 
504
- // Clear existing lanes
505
- lanes.forEach(lane => scene.remove(lane.mesh));
506
- lanes.clear();
 
 
 
507
 
508
- // Create lanes from server data
509
- Object.values(data.lanes).forEach(laneData => {
510
- createLaneFromData(laneData);
 
 
 
 
 
 
 
 
511
  });
 
 
 
 
512
 
513
  // Create player
514
  createPlayer();
515
- resetPlayerState(data.player);
516
 
517
  // Create other players
518
- data.players.forEach(p => {
519
- if (p.id !== myPlayerId) {
520
- createOtherPlayer(p);
521
- }
522
- });
523
 
 
 
524
  updatePlayerCount();
 
 
525
  }
526
 
527
- function handlePlayerJoined(playerData) {
528
- if (playerData.id !== myPlayerId) {
529
- createOtherPlayer(playerData);
530
  updatePlayerCount();
531
  }
532
  }
533
 
534
- function handlePlayerLeft(playerId) {
535
- const other = otherPlayers.get(playerId);
536
  if (other) {
537
  scene.remove(other.mesh);
538
- removePlayerLabel(playerId);
539
- otherPlayers.delete(playerId);
540
  updatePlayerCount();
541
  }
542
  }
543
 
544
- function handlePlayerMoved(data) {
545
  const other = otherPlayers.get(data.id);
546
  if (other) {
547
- // Store position update for interpolation
548
- const serverTime = data.hopStartTime - serverTimeOffset;
549
- other.positionBuffer.push({
550
- time: serverTime,
551
- x: data.worldX,
552
- z: data.worldZ,
553
- rotation: data.rotation,
554
- hopStartX: other.lastKnownX || data.worldX,
555
- hopStartZ: other.lastKnownZ || data.worldZ
 
556
  });
557
- other.lastKnownX = data.worldX;
558
- other.lastKnownZ = data.worldZ;
559
- other.score = data.score;
560
-
561
- // Keep buffer small
562
- while (other.positionBuffer.length > 20) {
563
- other.positionBuffer.shift();
564
  }
565
  }
566
  }
567
 
568
- function handlePlayerDied(data) {
569
- if (data.id === myPlayerId) {
570
- die(data.type === 'water');
571
  } else {
572
  const other = otherPlayers.get(data.id);
573
  if (other) {
@@ -581,86 +787,91 @@
581
  }
582
  }
583
 
584
- function handlePlayerRespawned(data) {
585
  const other = otherPlayers.get(data.id);
586
  if (other) {
587
  other.alive = true;
588
  other.mesh.position.set(0, 0, 0);
589
  other.mesh.scale.set(1, 1, 1);
590
- other.positionBuffer = [];
591
- other.lastKnownX = 0;
592
- other.lastKnownZ = 0;
593
  }
594
  }
595
 
596
- function handleMoveConfirmed(data) {
597
- // Remove from pending moves
598
- pendingMoves = pendingMoves.filter(m => m.seq !== data.seq);
599
-
600
- // Update score
601
- if (data.score > score) {
602
- score = data.score;
603
- document.getElementById('score').innerText = score;
604
  }
 
605
  }
606
 
607
- function handleMoveRejected(data) {
608
- // Remove from pending
609
- pendingMoves = pendingMoves.filter(m => m.seq !== data.seq);
610
-
611
- // Could implement rollback here if needed
612
- console.log('Move rejected:', data.reason);
613
  }
614
 
615
- function handleGameState(data) {
616
- // Update other players with interpolation data
617
- data.players.forEach(p => {
618
- if (p.id === myPlayerId) {
619
- // Could use for reconciliation
620
- } else {
 
 
 
 
 
 
621
  const other = otherPlayers.get(p.id);
622
  if (other) {
623
  other.serverState = p;
624
- other.alive = p.alive;
 
625
  }
626
  }
627
  });
628
 
629
- // Update obstacles
630
- Object.entries(data.obstacles).forEach(([laneIndex, obstacles]) => {
631
- const lane = lanes.get(parseInt(laneIndex));
632
- if (lane) {
633
- updateLaneObstacles(lane, obstacles);
634
- }
635
- });
636
-
637
- updatePlayerCount();
638
  }
639
 
640
- function handleNewLane(laneData) {
641
- createLaneFromData(laneData);
642
  }
643
 
644
- function handleLeaderboard(data) {
645
- const container = document.getElementById('leaderboard-entries');
646
- container.innerHTML = '';
647
-
648
- data.forEach((entry, index) => {
649
  const div = document.createElement('div');
650
- const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : 'bronze';
651
- div.className = `leaderboard-entry ${rankClass}`;
652
- div.innerHTML = `
653
- <span class="leaderboard-name">${escapeHtml(entry.username)}</span>
654
- <span>${entry.score}</span>
655
- `;
656
- container.appendChild(div);
657
  });
658
  }
659
 
660
- function handlePong(data) {
661
- ping = Date.now() - data.clientTime;
662
- serverTimeOffset = Date.now() - data.serverTime - ping / 2;
663
- document.getElementById('ping-display').textContent = `Ping: ${ping}ms`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  }
665
 
666
  function escapeHtml(text) {
@@ -669,311 +880,249 @@
669
  return div.innerHTML;
670
  }
671
 
672
- function updatePlayerCount() {
673
- const count = otherPlayers.size + 1;
674
- document.getElementById('player-count').textContent = `Players: ${count}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  }
676
 
677
- // --- LANE CREATION ---
678
- function createLaneFromData(laneData) {
 
 
 
 
 
 
679
  const lane = {
680
- index: laneData.index,
681
- type: laneData.type,
682
- staticObstacles: laneData.staticObstacles || [],
 
 
683
  mesh: new THREE.Group(),
684
  obstacles: [],
685
- obstacleMap: new Map(),
686
- zPos: laneData.zPos,
687
- speed: laneData.speed,
688
- direction: laneData.direction
 
 
 
689
  };
690
 
691
- let groundColor;
692
- let yPos;
693
-
694
- switch(laneData.type) {
695
- case 'grass':
696
- groundColor = (laneData.index % 2 === 0 ? COLORS.grass : COLORS.grassLight);
697
- yPos = -TILE_SIZE/2;
698
- break;
699
- case 'road':
700
- groundColor = COLORS.road;
701
- yPos = -TILE_SIZE/2;
702
- break;
703
- case 'water':
704
- groundColor = COLORS.water;
705
- yPos = -TILE_SIZE/2 - 8;
706
- break;
707
- }
708
-
709
- const ground = createVoxel(GRID_WIDTH * TILE_SIZE + 200, TILE_SIZE, TILE_SIZE, groundColor, 0, yPos, 0);
710
  lane.mesh.add(ground);
711
 
712
- if (laneData.type === 'road') {
713
- const line = createVoxel(GRID_WIDTH * TILE_SIZE, 1, 2, COLORS.roadLine, 0, yPos + 0.6, 0);
714
- lane.mesh.add(line);
715
  }
716
 
717
- lane.mesh.position.z = lane.zPos;
718
-
719
- // Add trees
720
- if (laneData.type === 'grass' && lane.staticObstacles) {
721
- lane.staticObstacles.forEach(gx => {
722
  const tree = new THREE.Group();
723
  tree.add(createVoxel(3, 5, 3, COLORS.treeTrunk, 0, 2.5, 0));
724
  tree.add(createVoxel(9, 9, 9, COLORS.treeLeaves, 0, 7, 0));
725
- tree.position.x = gx * TILE_SIZE;
726
  lane.mesh.add(tree);
727
  });
728
  }
729
 
 
730
  scene.add(lane.mesh);
731
- lanes.set(laneData.index, lane);
732
 
733
- // Create initial obstacles
734
- if (laneData.obstacles) {
735
- laneData.obstacles.forEach(obs => {
736
- createObstacle(lane, obs);
737
- });
738
  }
739
  }
740
 
741
- function createObstacle(lane, obsData) {
742
- const obs = {
743
- id: obsData.id,
744
- mesh: new THREE.Group(),
745
- isLog: obsData.isLog,
746
- width: obsData.width,
747
- x: obsData.x
748
- };
749
-
750
- if (obsData.isLog) {
751
- obs.mesh.add(createVoxel(obsData.width, 2, 7, COLORS.log, 0, -1, 0));
752
- } else {
753
  const color = Math.random() > 0.5 ? COLORS.carRed : COLORS.carBlue;
754
- obs.mesh.add(createVoxel(12, 7, 7, color, 0, 3.5, 0));
755
- obs.mesh.add(createVoxel(8, 3, 5, 0xFFFFFF, 0, 7, 0));
756
  }
757
 
758
- obs.mesh.position.set(obsData.x, 0, 0);
759
- lane.mesh.add(obs.mesh);
 
 
760
  lane.obstacles.push(obs);
761
- lane.obstacleMap.set(obsData.id, obs);
762
  }
763
 
764
- function updateLaneObstacles(lane, serverObstacles) {
765
- const serverIds = new Set(serverObstacles.map(o => o.id));
766
 
767
- // Remove obstacles that no longer exist
768
  for (let i = lane.obstacles.length - 1; i >= 0; i--) {
769
  const obs = lane.obstacles[i];
770
  if (!serverIds.has(obs.id)) {
771
  lane.mesh.remove(obs.mesh);
772
- lane.obstacleMap.delete(obs.id);
773
  lane.obstacles.splice(i, 1);
774
  }
775
  }
776
 
777
- // Update existing and add new obstacles
778
- serverObstacles.forEach(serverObs => {
779
- let obs = lane.obstacleMap.get(serverObs.id);
780
  if (obs) {
781
- // Smooth interpolation to server position
782
- obs.targetX = serverObs.x;
783
  } else {
784
- // Create new obstacle
785
- createObstacle(lane, serverObs);
786
- }
787
- });
788
- }
789
-
790
- // --- PLAYER CREATION ---
791
- function createVoxel(w, h, d, color, x, y, z) {
792
- const geo = new THREE.BoxGeometry(w, h, d);
793
- const mat = new THREE.MeshLambertMaterial({ color: color });
794
- const mesh = new THREE.Mesh(geo, mat);
795
- mesh.position.set(x, y, z);
796
- mesh.castShadow = true;
797
- mesh.receiveShadow = true;
798
- return mesh;
799
- }
800
-
801
- function createPlayerMesh(bodyColor = COLORS.penguinBody) {
802
- const group = new THREE.Group();
803
- const body = createVoxel(7, 9, 7, bodyColor, 0, 4.5, 0);
804
- const belly = createVoxel(5, 6, 2, COLORS.penguinBelly, 0, 4, 3);
805
- const beak = createVoxel(3, 2, 3, COLORS.penguinBeak, 0, 7.5, 3);
806
- const footL = createVoxel(2.5, 2, 2.5, COLORS.penguinBeak, -2, 1, 1);
807
- const footR = createVoxel(2.5, 2, 2.5, COLORS.penguinBeak, 2, 1, 1);
808
- group.add(body, belly, beak, footL, footR);
809
- return group;
810
- }
811
-
812
- function createPlayer() {
813
- if (player) {
814
- scene.remove(player);
815
- }
816
- player = createPlayerMesh();
817
- scene.add(player);
818
- }
819
-
820
- function createOtherPlayer(playerData) {
821
- const mesh = createPlayerMesh(COLORS.otherPlayer);
822
- mesh.position.set(playerData.worldX, 0, playerData.worldZ);
823
- scene.add(mesh);
824
-
825
- otherPlayers.set(playerData.id, {
826
- id: playerData.id,
827
- username: playerData.username,
828
- mesh: mesh,
829
- positionBuffer: [],
830
- lastKnownX: playerData.worldX,
831
- lastKnownZ: playerData.worldZ,
832
- alive: playerData.alive,
833
- score: playerData.score,
834
- serverState: playerData
835
- });
836
-
837
- createPlayerLabel(playerData.id, playerData.username);
838
- }
839
-
840
- function createPlayerLabel(playerId, username) {
841
- const label = document.createElement('div');
842
- label.className = 'player-label';
843
- label.id = `label-${playerId}`;
844
- label.textContent = username;
845
- document.getElementById('labels-container').appendChild(label);
846
- }
847
-
848
- function removePlayerLabel(playerId) {
849
- const label = document.getElementById(`label-${playerId}`);
850
- if (label) label.remove();
851
- }
852
-
853
- function updatePlayerLabels() {
854
- otherPlayers.forEach((other, playerId) => {
855
- const label = document.getElementById(`label-${playerId}`);
856
- if (label && other.alive) {
857
- const pos = other.mesh.position.clone();
858
- pos.y += 20;
859
-
860
- const vector = pos.project(camera);
861
- const x = (vector.x * 0.5 + 0.5) * window.innerWidth;
862
- const y = (-vector.y * 0.5 + 0.5) * window.innerHeight;
863
-
864
- if (vector.z < 1) {
865
- label.style.left = `${x}px`;
866
- label.style.top = `${y - 20}px`;
867
- label.style.display = 'block';
868
- } else {
869
- label.style.display = 'none';
870
- }
871
- } else if (label) {
872
- label.style.display = 'none';
873
  }
874
  });
875
  }
876
 
877
- // --- GAME STATE ---
878
- function resetPlayerState(playerData) {
 
 
 
 
 
 
879
  isGameOver = false;
880
- score = playerData.score;
881
- document.getElementById('score').innerText = score;
882
- document.getElementById('game-over').classList.remove('visible');
883
-
884
- playerGridX = playerData.gridX;
885
- playerGridZ = playerData.gridZ;
886
- player.position.set(playerData.worldX, 0, playerData.worldZ);
887
- player.rotation.y = playerData.rotation || 0;
888
- player.scale.set(1, 1, 1);
889
-
890
- attachedLog = null;
891
  isHopping = false;
892
- canMove = true;
893
  moveQueue = [];
894
- pendingMoves = [];
895
-
896
- currentLaneIndex = playerGridZ;
897
- maxLaneIndex = playerGridZ;
898
 
899
- camera.position.z = player.position.z + 100;
900
- camera.position.x = player.position.x - 100;
 
 
 
 
 
 
 
901
  }
902
 
903
- function resetGame() {
904
- if (!socket.connected) return;
905
- socket.emit('respawn');
906
  }
907
 
908
- // --- MOVEMENT ---
909
  function attemptMove(dx, dz) {
910
- if (isGameOver) return;
911
- if (!canMove) return;
912
-
913
- // Prevent backward movement
914
- if (dz < 0) return;
915
 
916
  const now = Date.now();
917
 
918
- // Anti-spam: enforce minimum time between moves
919
- if (now - lastMoveTime < MOVE_COOLDOWN) {
920
- // Queue the move if not too many queued
921
- if (moveQueue.length < MAX_QUEUE_SIZE) {
922
- moveQueue.push({dx, dz});
923
  }
924
  return;
925
  }
926
 
927
  if (isHopping) {
928
- if (moveQueue.length < MAX_QUEUE_SIZE) {
929
- moveQueue.push({dx, dz});
930
  }
931
  return;
932
  }
933
 
934
- const targetX = playerGridX + dx;
935
- const targetZ = playerGridZ + dz;
936
 
937
  // Bounds check
938
- if (Math.abs(targetX) > Math.floor(GRID_WIDTH/2)) return;
939
 
940
- // Check for tree blocking
941
- const targetLane = lanes.get(targetZ);
942
- if (targetLane && targetLane.type === 'grass') {
943
- if (targetLane.staticObstacles.includes(targetX)) return;
944
- }
945
 
946
- // Execute move locally (client-side prediction)
947
  lastMoveTime = now;
948
  isHopping = true;
949
  hopStartTime = now;
950
- attachedLog = null;
951
-
952
- // Set rotation
 
 
 
 
 
 
953
  if (dx === 1) player.rotation.y = -Math.PI / 2;
954
  else if (dx === -1) player.rotation.y = Math.PI / 2;
955
  else if (dz === 1) player.rotation.y = 0;
956
  else if (dz === -1) player.rotation.y = Math.PI;
957
 
958
- hopStartPos.copy(player.position);
959
- hopTargetPos.set(targetX * TILE_SIZE, 0, targetZ * TILE_SIZE);
960
-
961
- playerGridX = targetX;
962
- playerGridZ = targetZ;
963
-
964
- // Update score locally
965
- if (targetZ > maxLaneIndex) {
966
- maxLaneIndex = targetZ;
967
- score = maxLaneIndex;
968
- document.getElementById('score').innerText = score;
969
  }
970
 
971
  // Send to server
972
- const seq = ++moveSequence;
973
- pendingMoves.push({ seq, dx, dz, time: now });
974
- socket.emit('move', { dx, dz, seq });
975
 
976
- document.getElementById('tutorial').style.display = 'none';
977
  }
978
 
979
  function processHop() {
@@ -981,34 +1130,27 @@
981
 
982
  const now = Date.now();
983
  const elapsed = now - hopStartTime;
984
- const progress = Math.min(elapsed / HOP_DURATION, 1);
985
 
986
- player.position.lerpVectors(hopStartPos, hopTargetPos, progress);
987
- player.position.y = Math.sin(progress * Math.PI) * 9;
 
988
 
989
- // Squash and stretch
990
- if (progress < 1) {
991
- player.scale.set(0.9, 1.1, 0.9);
992
- }
993
 
994
- // Check collision DURING hop (fix for spam-click bug)
995
- checkMidHopCollision(progress);
996
 
997
- if (progress >= 1) {
998
  isHopping = false;
999
- player.position.copy(hopTargetPos);
1000
- player.position.y = 0;
1001
-
1002
- // Landing squash
1003
  player.scale.set(1.3, 0.7, 1.3);
1004
- setTimeout(() => {
1005
- if (player) player.scale.set(1, 1, 1);
1006
- }, 60);
1007
 
1008
- // Check collision at landing
1009
- checkCollisions();
1010
 
1011
- // Process queued move
1012
  if (moveQueue.length > 0 && !isGameOver) {
1013
  const next = moveQueue.shift();
1014
  setTimeout(() => attemptMove(next.dx, next.dz), 10);
@@ -1016,210 +1158,201 @@
1016
  }
1017
  }
1018
 
1019
- function checkMidHopCollision(progress) {
1020
  if (isGameOver) return;
1021
 
1022
- // Calculate current position during hop
1023
- const currentX = hopStartPos.x + (hopTargetPos.x - hopStartPos.x) * progress;
1024
- const currentZ = hopStartPos.z + (hopTargetPos.z - hopStartPos.z) * progress;
1025
- const currentGridZ = Math.round(currentZ / TILE_SIZE);
1026
 
1027
- const lane = lanes.get(currentGridZ);
1028
- if (!lane || lane.type !== 'road') return;
1029
 
1030
- // Check car collision
1031
  for (const obs of lane.obstacles) {
1032
  if (obs.isLog) continue;
1033
-
1034
- const obsX = obs.mesh.position.x;
1035
- const halfWidth = obs.width / 2 + 3;
1036
-
1037
- if (Math.abs(currentX - obsX) < halfWidth) {
1038
- die(false);
1039
  return;
1040
  }
1041
  }
1042
  }
1043
 
1044
- function checkCollisions() {
1045
  if (isGameOver) return;
1046
 
1047
- const lane = lanes.get(playerGridZ);
1048
- if (!lane) return;
1049
-
1050
- if (lane.type === 'grass') return;
1051
 
1052
- // Bounds check
1053
- if (Math.abs(player.position.x) > (GRID_WIDTH * TILE_SIZE / 2 + 10)) {
1054
- die(false);
1055
  return;
1056
  }
1057
 
1058
  let onLog = false;
1059
 
1060
  for (const obs of lane.obstacles) {
1061
- const obsX = obs.mesh.position.x;
1062
 
1063
  if (obs.isLog) {
1064
- const distX = Math.abs(player.position.x - obsX);
1065
- const safeDist = (obs.width / 2) + 4;
1066
-
1067
- if (distX < safeDist) {
1068
  onLog = true;
1069
- if (attachedLog !== obs) {
1070
- attachedLog = obs;
1071
- attachedLogOffset = player.position.x - obsX;
1072
- }
1073
  }
1074
  } else {
1075
- // Car collision
1076
- const halfWidth = obs.width / 2 + 3;
1077
- if (Math.abs(player.position.x - obsX) < halfWidth) {
1078
- die(false);
1079
  return;
1080
  }
1081
  }
1082
  }
1083
 
1084
- if (lane.type === 'water' && !onLog) {
1085
- die(true);
1086
- } else if (lane.type !== 'water') {
1087
- attachedLog = null;
1088
  }
1089
  }
1090
 
1091
- function die(drown = false) {
1092
  if (isGameOver) return;
1093
  isGameOver = true;
1094
  isHopping = false;
1095
  moveQueue = [];
1096
-
1097
- document.getElementById('game-over').classList.add('visible');
1098
-
1099
  if (drown) {
1100
  player.position.y = -10;
1101
  } else {
1102
  player.scale.set(1.5, 0.1, 1.5);
1103
  }
 
 
 
 
 
 
 
 
1104
  }
1105
 
1106
- // --- OBSTACLE INTERPOLATION ---
1107
- function updateObstacles() {
1108
  lanes.forEach(lane => {
1109
  lane.obstacles.forEach(obs => {
1110
  if (obs.targetX !== undefined) {
1111
- // Smooth interpolation
1112
  const diff = obs.targetX - obs.mesh.position.x;
1113
- obs.mesh.position.x += diff * 0.3;
1114
  obs.x = obs.mesh.position.x;
1115
  }
1116
  });
1117
  });
1118
  }
1119
 
1120
- // --- OTHER PLAYER INTERPOLATION ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1121
  function updateOtherPlayers() {
1122
- const renderTime = Date.now() - INTERPOLATION_DELAY;
1123
 
1124
- otherPlayers.forEach((other) => {
1125
  if (!other.alive) return;
1126
 
1127
- const buffer = other.positionBuffer;
1128
-
1129
  if (buffer.length >= 2) {
1130
- // Find the two positions to interpolate between
1131
  let i = 0;
1132
- while (i < buffer.length - 1 && buffer[i + 1].time <= renderTime) {
1133
- i++;
1134
- }
1135
 
1136
  if (i < buffer.length - 1) {
1137
  const p1 = buffer[i];
1138
  const p2 = buffer[i + 1];
1139
- const t = (renderTime - p1.time) / (p2.time - p1.time);
1140
- const clampedT = Math.max(0, Math.min(1, t));
1141
-
1142
- // Interpolate position with hop animation
1143
- other.mesh.position.x = p1.x + (p2.x - p1.x) * clampedT;
1144
- other.mesh.position.z = p1.z + (p2.z - p1.z) * clampedT;
1145
- other.mesh.position.y = Math.sin(clampedT * Math.PI) * 9;
1146
- other.mesh.rotation.y = p2.rotation;
1147
-
1148
- // Squash and stretch
1149
- if (clampedT < 1) {
1150
- other.mesh.scale.set(0.9, 1.1, 0.9);
1151
- } else {
1152
- other.mesh.scale.set(1, 1, 1);
1153
- }
1154
  } else if (buffer.length > 0) {
1155
- // Use latest position
1156
- const latest = buffer[buffer.length - 1];
1157
- other.mesh.position.x = latest.x;
1158
- other.mesh.position.z = latest.z;
1159
- other.mesh.position.y = 0;
1160
- other.mesh.rotation.y = latest.rotation;
1161
  other.mesh.scale.set(1, 1, 1);
1162
  }
1163
 
1164
- // Clean old buffer entries
1165
- while (buffer.length > 2 && buffer[1].time < renderTime) {
1166
- buffer.shift();
1167
- }
1168
- } else if (buffer.length === 1) {
1169
- const pos = buffer[0];
1170
- other.mesh.position.x = pos.x;
1171
- other.mesh.position.z = pos.z;
1172
- other.mesh.position.y = 0;
1173
  }
1174
  });
1175
  }
1176
 
1177
- // --- LOG RIDING ---
1178
- function updateLogRiding() {
1179
- if (isHopping || !attachedLog || isGameOver) return;
 
1180
 
1181
- const lane = lanes.get(playerGridZ);
1182
- if (!lane) return;
 
 
1183
 
1184
- // Find the log in the lane
1185
- const log = lane.obstacles.find(o => o === attachedLog);
1186
- if (log) {
1187
- player.position.x = log.mesh.position.x + attachedLogOffset;
1188
- playerGridX = Math.round(player.position.x / TILE_SIZE);
1189
 
1190
- // Check if pushed off screen
1191
- if (Math.abs(player.position.x) > (GRID_WIDTH * TILE_SIZE / 2 + 10)) {
1192
- die(true);
1193
- }
1194
- } else {
1195
- attachedLog = null;
1196
- // Check if still on water without log
1197
- if (lane.type === 'water') {
1198
- checkCollisions();
 
1199
  }
1200
- }
 
 
 
 
 
 
 
 
 
1201
  }
1202
 
1203
- // --- ANIMATION LOOP ---
1204
  function animate() {
1205
  requestAnimationFrame(animate);
1206
 
1207
  processHop();
1208
- updateObstacles();
1209
- updateOtherPlayers();
1210
  updateLogRiding();
1211
-
1212
- if (!isGameOver) {
1213
- // Camera follow
1214
- const targetZ = player.position.z + 100;
1215
- const targetX = player.position.x - 100;
1216
- camera.position.z += (targetZ - camera.position.z) * 0.1;
1217
- camera.position.x += (targetX - camera.position.x) * 0.05;
1218
-
1219
- currentLaneIndex = Math.max(currentLaneIndex, playerGridZ);
1220
- }
1221
 
1222
- updatePlayerLabels();
1223
  renderer.render(scene, camera);
1224
  }
1225
 
@@ -1234,57 +1367,99 @@
1234
  renderer.setSize(window.innerWidth, window.innerHeight);
1235
  }
1236
 
1237
- function setupInputs() {
1238
- const c = document.getElementById('game-container');
1239
-
1240
- c.addEventListener('touchmove', e => e.preventDefault(), {passive: false});
 
 
 
 
 
 
 
 
 
 
 
 
1241
 
1242
- c.addEventListener('touchstart', e => {
1243
- touchStartX = e.touches[0].clientX;
1244
- touchStartY = e.touches[0].clientY;
1245
- }, {passive: false});
 
 
 
 
 
 
 
 
 
 
 
 
1246
 
1247
- c.addEventListener('touchend', e => {
1248
- if (isGameOver) return;
1249
- e.preventDefault();
1250
- handleSwipe(
1251
- e.changedTouches[0].clientX - touchStartX,
1252
- e.changedTouches[0].clientY - touchStartY
1253
- );
1254
- }, {passive: false});
1255
-
1256
- let mX, mY;
1257
- c.addEventListener('mousedown', e => { mX = e.clientX; mY = e.clientY; });
1258
- c.addEventListener('mouseup', e => {
1259
- if (isGameOver) return;
1260
- handleSwipe(e.clientX - mX, e.clientY - mY);
1261
  });
1262
 
 
1263
  document.addEventListener('keydown', e => {
1264
- if (e.key === 'ArrowUp' || e.key === 'w') attemptMove(0, 1);
1265
- if (e.key === 'ArrowDown' || e.key === 's') attemptMove(0, -1);
1266
- if (e.key === 'ArrowLeft' || e.key === 'a') attemptMove(1, 0);
1267
- if (e.key === 'ArrowRight' || e.key === 'd') attemptMove(-1, 0);
 
 
 
 
1268
  });
1269
 
1270
- document.getElementById('restart-btn').addEventListener('click', resetGame);
 
 
 
 
 
 
 
 
 
 
 
 
1271
  }
1272
 
1273
  function handleSwipe(dx, dy) {
1274
- if (Math.abs(dx) < 10 && Math.abs(dy) < 10) {
1275
- attemptMove(0, 1);
1276
- return;
1277
  }
 
1278
  if (Math.abs(dx) > Math.abs(dy)) {
1279
- if (dx > 0) attemptMove(1, 0);
1280
- else attemptMove(-1, 0);
1281
  } else {
1282
- if (dy < 0) attemptMove(0, 1);
1283
- else attemptMove(0, -1);
1284
  }
1285
  }
1286
 
 
 
 
 
 
 
 
 
 
 
 
1287
  init();
 
1288
  </script>
1289
  </body>
1290
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
  <title>Voxel Hopper: Multiplayer</title>
7
  <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
  body, html {
 
 
11
  width: 100%;
12
  height: 100%;
13
+ background: #1a1a2e;
14
  overflow: hidden;
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
  touch-action: none;
17
  user-select: none;
18
  -webkit-user-select: none;
19
+ -webkit-touch-callout: none;
20
  }
21
 
22
  #game-container {
23
  position: relative;
24
  width: 100%;
25
  height: 100%;
26
+ background: #87CEEB;
27
  }
28
 
29
+ canvas { display: block; width: 100%; height: 100%; }
 
 
 
 
 
30
 
31
+ /* ===== LOGIN SCREEN ===== */
32
  #login-screen {
33
  position: absolute;
34
+ inset: 0;
35
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
 
 
 
36
  display: flex;
37
  flex-direction: column;
38
  justify-content: center;
39
  align-items: center;
40
+ z-index: 1000;
41
+ padding: 20px;
42
  }
43
 
44
+ .login-content {
45
+ text-align: center;
46
+ max-width: 320px;
47
+ width: 100%;
48
+ }
49
+
50
+ .game-logo {
51
+ font-size: 64px;
52
  margin-bottom: 10px;
53
+ animation: bounce 2s infinite;
54
  }
55
 
56
+ @keyframes bounce {
57
+ 0%, 100% { transform: translateY(0); }
58
+ 50% { transform: translateY(-10px); }
59
+ }
60
+
61
+ .login-content h1 {
62
+ color: #fff;
63
+ font-size: 32px;
64
+ margin-bottom: 5px;
65
+ text-shadow: 2px 2px 0 #000;
66
+ }
67
+
68
+ .login-content .subtitle {
69
+ color: #4CAF50;
70
+ font-size: 14px;
71
  margin-bottom: 30px;
72
+ text-transform: uppercase;
73
+ letter-spacing: 2px;
74
  }
75
 
76
  #username-input {
77
+ width: 100%;
78
+ padding: 16px 20px;
79
+ font-size: 18px;
80
+ border: 3px solid #333;
81
+ border-radius: 12px;
82
+ background: #fff;
83
  text-align: center;
84
  font-family: inherit;
85
+ outline: none;
86
+ transition: border-color 0.2s;
87
+ }
88
+
89
+ #username-input:focus {
90
+ border-color: #4CAF50;
91
  }
92
 
93
  #join-btn {
94
+ width: 100%;
95
+ padding: 16px;
96
+ margin-top: 15px;
97
+ font-size: 18px;
98
  font-weight: bold;
99
+ background: linear-gradient(180deg, #4CAF50 0%, #388E3C 100%);
100
  color: white;
101
  border: none;
102
+ border-radius: 12px;
103
  cursor: pointer;
104
+ box-shadow: 0 4px 0 #2E7D32;
105
+ transition: all 0.1s;
106
+ text-transform: uppercase;
107
+ letter-spacing: 1px;
108
  }
109
 
110
+ #join-btn:active:not(:disabled) {
111
+ transform: translateY(2px);
112
+ box-shadow: 0 2px 0 #2E7D32;
113
  }
114
 
115
  #join-btn:disabled {
116
+ background: linear-gradient(180deg, #666 0%, #444 100%);
117
+ box-shadow: 0 4px 0 #333;
118
+ cursor: not-allowed;
119
  }
120
 
121
+ /* Connection Status Indicator */
122
+ .connection-box {
123
+ margin-top: 20px;
124
+ padding: 12px 20px;
125
+ border-radius: 8px;
126
+ font-size: 13px;
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: center;
130
+ gap: 8px;
131
+ transition: all 0.3s;
132
+ }
133
+
134
+ .connection-box.connecting {
135
+ background: rgba(255, 152, 0, 0.2);
136
+ color: #FFB74D;
137
+ }
138
+
139
+ .connection-box.connected {
140
+ background: rgba(76, 175, 80, 0.2);
141
+ color: #81C784;
142
  }
143
 
144
+ .connection-box.error {
145
+ background: rgba(244, 67, 54, 0.2);
146
+ color: #E57373;
147
+ }
148
+
149
+ .connection-dot {
150
+ width: 8px;
151
+ height: 8px;
152
+ border-radius: 50%;
153
+ animation: pulse-dot 1.5s infinite;
154
+ }
155
+
156
+ .connecting .connection-dot { background: #FF9800; }
157
+ .connected .connection-dot { background: #4CAF50; animation: none; }
158
+ .error .connection-dot { background: #f44336; }
159
 
160
+ @keyframes pulse-dot {
161
+ 0%, 100% { opacity: 1; transform: scale(1); }
162
+ 50% { opacity: 0.5; transform: scale(0.8); }
163
+ }
164
+
165
+ .retry-info {
166
+ color: #888;
167
+ font-size: 11px;
168
+ margin-top: 8px;
169
+ }
170
+
171
+ /* Loading Spinner */
172
+ .spinner {
173
+ width: 20px;
174
+ height: 20px;
175
+ border: 2px solid rgba(255,255,255,0.3);
176
+ border-top-color: #fff;
177
+ border-radius: 50%;
178
+ animation: spin 0.8s linear infinite;
179
+ }
180
+
181
+ @keyframes spin { to { transform: rotate(360deg); } }
182
+
183
+ /* ===== GAME UI ===== */
184
  .ui-layer {
185
  position: absolute;
186
+ inset: 0;
 
 
 
187
  pointer-events: none;
 
 
 
188
  }
189
 
 
 
 
 
 
 
 
 
190
  #score {
191
+ position: absolute;
192
+ top: 50px;
193
+ left: 20px;
194
+ font-size: 72px;
195
  font-weight: 900;
196
  color: white;
197
+ text-shadow: 3px 3px 0 rgba(0,0,0,0.3);
 
198
  line-height: 1;
199
  }
200
 
201
+ /* Leaderboard - Safe area for Dynamic Island */
202
  #leaderboard {
203
  position: absolute;
204
+ top: 55px;
205
+ right: 10px;
206
+ background: rgba(0,0,0,0.7);
207
+ padding: 10px 12px;
208
+ border-radius: 10px;
209
+ min-width: 120px;
210
  backdrop-filter: blur(10px);
211
+ -webkit-backdrop-filter: blur(10px);
212
  }
213
 
214
  #leaderboard h3 {
 
215
  color: #FFD700;
216
+ font-size: 12px;
217
+ margin-bottom: 6px;
218
  text-transform: uppercase;
219
+ letter-spacing: 1px;
220
  }
221
 
222
+ .lb-entry {
223
  display: flex;
224
  justify-content: space-between;
225
  color: white;
226
+ font-size: 12px;
227
+ padding: 2px 0;
228
  }
229
 
230
+ .lb-entry.gold { color: #FFD700; }
231
+ .lb-entry.silver { color: #C0C0C0; }
232
+ .lb-entry.bronze { color: #CD7F32; }
233
 
234
+ .lb-name {
235
+ max-width: 80px;
236
  overflow: hidden;
237
  text-overflow: ellipsis;
238
  white-space: nowrap;
239
  }
240
 
241
+ /* Network Stats */
242
+ #net-stats {
243
  position: absolute;
244
+ bottom: 8px;
245
+ left: 8px;
246
+ display: flex;
247
+ gap: 10px;
248
+ font-size: 10px;
249
+ color: rgba(255,255,255,0.5);
250
+ }
251
+
252
+ .stat { display: flex; align-items: center; gap: 4px; }
253
+ .stat-dot {
254
+ width: 6px;
255
+ height: 6px;
256
+ border-radius: 50%;
257
  }
258
+ .stat-dot.good { background: #4CAF50; }
259
+ .stat-dot.medium { background: #FF9800; }
260
+ .stat-dot.poor { background: #f44336; }
261
 
262
+ /* Player Count */
263
  #player-count {
264
  position: absolute;
265
+ top: 130px;
266
  left: 20px;
267
+ color: rgba(255,255,255,0.6);
268
  font-size: 12px;
269
  }
270
 
271
+ /* Tutorial */
 
272
  #tutorial {
273
+ position: absolute;
274
+ bottom: 100px;
275
+ left: 50%;
276
+ transform: translateX(-50%);
277
  color: white;
278
  font-weight: bold;
279
+ font-size: 20px;
280
  text-transform: uppercase;
281
  text-shadow: 2px 2px 0 rgba(0,0,0,0.3);
282
  animation: pulse 1.5s infinite;
283
  }
284
 
285
+ @keyframes pulse {
286
+ 0%, 100% { opacity: 0.7; }
287
+ 50% { opacity: 1; transform: translateX(-50%) scale(1.05); }
288
+ }
289
+
290
+ /* Game Over */
291
  #game-over {
292
  position: absolute;
293
+ inset: 0;
294
+ background: rgba(0,0,0,0.7);
295
  display: flex;
296
  flex-direction: column;
297
  justify-content: center;
298
  align-items: center;
299
  opacity: 0;
300
  pointer-events: none;
301
+ transition: opacity 0.3s;
302
  z-index: 100;
303
  }
304
 
305
+ #game-over.visible {
306
+ opacity: 1;
307
+ pointer-events: auto;
308
+ }
309
+
310
+ #game-over h1 {
311
+ color: white;
312
+ font-size: 56px;
313
+ text-shadow: 4px 4px 0 #000;
314
+ margin-bottom: 10px;
315
+ }
316
+
317
+ #final-score {
318
+ color: #FFD700;
319
+ font-size: 24px;
320
+ margin-bottom: 30px;
321
+ }
322
 
323
  .btn-restart {
324
  background: #fff;
325
  color: #333;
326
  border: none;
327
+ padding: 18px 50px;
328
+ font-size: 20px;
329
  font-weight: 900;
330
  text-transform: uppercase;
331
  border-radius: 12px;
332
+ box-shadow: 0 6px 0 #999;
333
  cursor: pointer;
 
334
  transition: transform 0.1s;
 
335
  }
 
336
 
337
+ .btn-restart:active {
338
+ transform: translateY(3px);
339
+ box-shadow: 0 3px 0 #999;
340
+ }
341
+
342
+ /* Player Labels */
343
+ #labels-container {
344
+ position: absolute;
345
+ inset: 0;
346
+ pointer-events: none;
347
+ overflow: hidden;
348
  }
349
 
 
350
  .player-label {
351
  position: absolute;
352
  color: white;
353
+ font-size: 11px;
354
  font-weight: bold;
355
+ text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
 
356
  white-space: nowrap;
357
  transform: translateX(-50%);
358
+ padding: 2px 6px;
359
+ background: rgba(0,0,0,0.4);
360
+ border-radius: 4px;
361
  }
362
 
363
+ /* Reconnecting Overlay */
364
+ #reconnect-overlay {
365
  position: absolute;
366
+ inset: 0;
367
+ background: rgba(0,0,0,0.8);
368
+ display: none;
369
+ flex-direction: column;
370
+ justify-content: center;
371
+ align-items: center;
372
+ z-index: 500;
373
+ color: white;
374
+ }
375
+
376
+ #reconnect-overlay.visible { display: flex; }
377
+
378
+ #reconnect-overlay h2 {
379
+ margin-top: 20px;
380
+ font-size: 24px;
381
+ }
382
+
383
+ #reconnect-overlay p {
384
+ color: #888;
385
+ margin-top: 10px;
386
  }
387
  </style>
388
  </head>
 
390
 
391
  <div id="game-container">
392
  <canvas id="canvas"></canvas>
 
393
  <div id="labels-container"></div>
394
 
395
  <div class="ui-layer">
396
+ <div id="score">0</div>
397
+ <div id="player-count">Players: 0</div>
398
+ <div id="tutorial">Swipe or Tap to Move</div>
 
 
 
399
  </div>
400
 
401
  <div id="leaderboard">
402
  <h3>🏆 Top 3</h3>
403
+ <div id="lb-entries"></div>
404
  </div>
405
 
406
+ <div id="net-stats">
407
+ <div class="stat">
408
+ <div class="stat-dot good" id="conn-dot"></div>
409
+ <span id="ping-display">--ms</span>
410
+ </div>
411
+ <div class="stat" id="players-online">0 online</div>
412
+ </div>
413
 
414
  <div id="game-over">
415
+ <h1>💀 SPLAT!</h1>
416
+ <div id="final-score">Score: 0</div>
417
+ <button class="btn-restart" id="restart-btn">Play Again</button>
418
+ </div>
419
+
420
+ <div id="reconnect-overlay">
421
+ <div class="spinner" style="width:40px;height:40px;border-width:4px;"></div>
422
+ <h2>Reconnecting...</h2>
423
+ <p id="reconnect-info">Attempting to reconnect</p>
424
  </div>
425
 
426
  <div id="login-screen">
427
+ <div class="login-content">
428
+ <div class="game-logo">🐧</div>
429
+ <h1>VOXEL HOPPER</h1>
430
+ <div class="subtitle">Multiplayer</div>
431
+
432
+ <input type="text" id="username-input" placeholder="Enter your name" maxlength="12" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
433
+ <button id="join-btn" disabled>
434
+ <span id="join-btn-text">Connecting...</span>
435
+ </button>
436
+
437
+ <div class="connection-box connecting" id="conn-status">
438
+ <div class="connection-dot"></div>
439
+ <span id="conn-text">Connecting to server...</span>
440
+ </div>
441
+ <div class="retry-info" id="retry-info"></div>
442
+ </div>
443
  </div>
444
  </div>
445
 
446
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
447
+ <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
448
 
449
  <script>
450
+ (function() {
451
+ 'use strict';
452
+
453
+ // ============= CONFIGURATION =============
454
+ const CONFIG = {
455
+ TILE_SIZE: 10,
456
+ GRID_WIDTH: 13,
457
+ HOP_DURATION: 120,
458
+ MOVE_COOLDOWN: 90,
459
+ MAX_QUEUE: 2,
460
+ INTERP_DELAY: 100,
461
+ RECONNECT_DELAY: 1000,
462
+ MAX_RECONNECT_DELAY: 10000,
463
+ PING_INTERVAL: 3000
464
+ };
465
 
 
466
  const COLORS = {
467
+ sky: 0x87CEEB,
468
  grass: 0x71AA34, grassLight: 0x86D455,
469
+ road: 0x222222, roadLine: 0xFFFFFF,
 
470
  water: 0x00BFFF,
471
  treeTrunk: 0x8B5A2B, treeLeaves: 0x4CAF50,
472
+ penguin: 0x222222, belly: 0xFFFFFF, beak: 0xFFCC00,
473
  carRed: 0xE74C3C, carBlue: 0x3498DB,
474
  log: 0x5D4037,
475
  otherPlayer: 0xFF6B6B
476
  };
477
 
478
+ // ============= STATE =============
479
+ let socket = null;
480
+ let myId = null;
481
+ let serverTimeDiff = 0;
482
  let ping = 0;
483
  let lastPingTime = 0;
484
+ let moveSeq = 0;
485
+ let reconnectAttempts = 0;
486
+ let isConnected = false;
487
+ let hasJoined = false;
488
 
 
489
  let scene, camera, renderer;
490
+ let player = null;
491
  let otherPlayers = new Map();
492
  let lanes = new Map();
493
+
494
+ let playerGX = 0, playerGZ = 0;
495
+ let playerWX = 0, playerWZ = 0;
496
  let score = 0;
497
+ let maxScore = 0;
498
  let isGameOver = false;
499
  let isHopping = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  let hopStartTime = 0;
501
+ let hopStartPos = { x: 0, z: 0 };
502
+ let hopTargetPos = { x: 0, z: 0 };
503
+ let attachedLogId = null;
504
+ let lastMoveTime = 0;
505
  let moveQueue = [];
 
506
 
507
+ // Touch
508
+ let touchStartX = 0, touchStartY = 0;
509
+ let touchStartTime = 0;
510
+
511
+ // ============= DOM ELEMENTS =============
512
+ const $canvas = document.getElementById('canvas');
513
+ const $login = document.getElementById('login-screen');
514
+ const $nameInput = document.getElementById('username-input');
515
+ const $joinBtn = document.getElementById('join-btn');
516
+ const $joinBtnText = document.getElementById('join-btn-text');
517
+ const $connStatus = document.getElementById('conn-status');
518
+ const $connText = document.getElementById('conn-text');
519
+ const $retryInfo = document.getElementById('retry-info');
520
+ const $reconnectOverlay = document.getElementById('reconnect-overlay');
521
+ const $reconnectInfo = document.getElementById('reconnect-info');
522
+ const $score = document.getElementById('score');
523
+ const $tutorial = document.getElementById('tutorial');
524
+ const $gameOver = document.getElementById('game-over');
525
+ const $finalScore = document.getElementById('final-score');
526
+ const $restartBtn = document.getElementById('restart-btn');
527
+ const $lbEntries = document.getElementById('lb-entries');
528
+ const $pingDisplay = document.getElementById('ping-display');
529
+ const $connDot = document.getElementById('conn-dot');
530
+ const $playersOnline = document.getElementById('players-online');
531
+ const $playerCount = document.getElementById('player-count');
532
+ const $labelsContainer = document.getElementById('labels-container');
533
+
534
+ // ============= INITIALIZATION =============
535
  function init() {
536
+ initThree();
537
+ initSocket();
538
+ initInputs();
539
+ animate();
540
+ }
541
+
542
+ function initThree() {
543
  scene = new THREE.Scene();
544
  scene.background = new THREE.Color(COLORS.sky);
545
  scene.fog = new THREE.Fog(COLORS.sky, 160, 280);
546
 
547
  const aspect = window.innerWidth / window.innerHeight;
548
+ const d = 100;
549
  camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
550
+ camera.position.set(-100, 100, 100);
551
  camera.lookAt(0, 0, 0);
552
 
553
+ renderer = new THREE.WebGLRenderer({ canvas: $canvas, antialias: true });
 
554
  renderer.setSize(window.innerWidth, window.innerHeight);
555
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
556
  renderer.shadowMap.enabled = true;
557
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
558
 
559
  // Lights
560
+ scene.add(new THREE.AmbientLight(0xffffff, 0.75));
 
561
  const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
562
  dirLight.position.set(-50, 100, 50);
563
  dirLight.castShadow = true;
564
+ dirLight.shadow.mapSize.set(1024, 1024);
565
+ const s = 100;
566
+ dirLight.shadow.camera.left = -s;
567
+ dirLight.shadow.camera.right = s;
568
+ dirLight.shadow.camera.top = s;
569
+ dirLight.shadow.camera.bottom = -s;
 
570
  scene.add(dirLight);
571
 
572
  window.addEventListener('resize', onResize);
 
 
 
 
573
  }
574
 
575
+ // ============= SOCKET MANAGEMENT =============
576
+ function initSocket() {
577
+ updateConnectionUI('connecting', 'Connecting to server...');
 
 
578
 
579
+ const socketOptions = {
 
 
580
  transports: ['websocket', 'polling'],
581
+ upgrade: true,
582
+ rememberUpgrade: true,
583
+ timeout: 20000,
584
  reconnection: true,
585
+ reconnectionDelay: CONFIG.RECONNECT_DELAY,
586
+ reconnectionDelayMax: CONFIG.MAX_RECONNECT_DELAY,
587
+ reconnectionAttempts: Infinity,
588
+ forceNew: false
589
+ };
 
 
 
 
 
 
590
 
591
+ socket = io(window.location.origin, socketOptions);
592
+
593
+ // Connection events
594
+ socket.on('connect', onConnect);
595
+ socket.on('disconnect', onDisconnect);
596
+ socket.on('connect_error', onConnectError);
597
+ socket.on('reconnect_attempt', onReconnectAttempt);
598
+ socket.on('reconnect', onReconnect);
599
+
600
+ // Game events
601
+ socket.on('ack', onAck);
602
+ socket.on('init', onInit);
603
+ socket.on('pj', onPlayerJoin);
604
+ socket.on('pl', onPlayerLeave);
605
+ socket.on('pm', onPlayerMove);
606
+ socket.on('pd', onPlayerDie);
607
+ socket.on('prs', onPlayerRespawn);
608
+ socket.on('mc', onMoveConfirm);
609
+ socket.on('mr', onMoveReject);
610
+ socket.on('gs', onGameState);
611
+ socket.on('nl', onNewLane);
612
+ socket.on('lb', onLeaderboard);
613
+ socket.on('po', onPong);
614
+ socket.on('rsd', onRespawned);
615
+ socket.on('err', onError);
616
+
617
+ // Heartbeat
618
+ setInterval(() => {
619
+ if (socket && socket.connected) {
620
+ socket.emit('hb');
621
+ }
622
+ }, 5000);
623
 
624
+ // Ping
625
+ setInterval(() => {
626
+ if (socket && socket.connected && hasJoined) {
627
+ lastPingTime = Date.now();
628
+ socket.emit('p', lastPingTime);
629
+ }
630
+ }, CONFIG.PING_INTERVAL);
631
+ }
632
 
633
+ function onConnect() {
634
+ console.log('Connected:', socket.id);
635
+ isConnected = true;
636
+ reconnectAttempts = 0;
637
+
638
+ if (hasJoined) {
639
+ $reconnectOverlay.classList.remove('visible');
640
+ // Re-join with same name
641
+ const name = $nameInput.value.trim() || 'Anon';
642
+ socket.emit('join', { name });
643
+ } else {
644
+ updateConnectionUI('connected', 'Connected!');
645
+ $joinBtn.disabled = false;
646
+ $joinBtnText.textContent = 'Play';
647
+ }
648
+ }
649
 
650
+ function onDisconnect(reason) {
651
+ console.log('Disconnected:', reason);
652
+ isConnected = false;
653
+
654
+ if (hasJoined) {
655
+ $reconnectOverlay.classList.add('visible');
656
+ $reconnectInfo.textContent = 'Connection lost...';
657
+ } else {
658
+ updateConnectionUI('error', 'Disconnected');
659
+ $joinBtn.disabled = true;
660
+ $joinBtnText.textContent = 'Reconnecting...';
661
+ }
662
+ }
663
 
664
+ function onConnectError(err) {
665
+ console.log('Connection error:', err.message);
666
+ reconnectAttempts++;
667
+
668
+ if (!hasJoined) {
669
+ updateConnectionUI('error', 'Connection failed');
670
+ $retryInfo.textContent = `Retry ${reconnectAttempts}...`;
671
+ $joinBtn.disabled = true;
672
+ $joinBtnText.textContent = 'Reconnecting...';
673
+ }
674
+ }
675
 
676
+ function onReconnectAttempt(attempt) {
677
+ console.log('Reconnect attempt:', attempt);
678
+ reconnectAttempts = attempt;
679
+
680
+ if (hasJoined) {
681
+ $reconnectInfo.textContent = `Attempt ${attempt}...`;
682
+ } else {
683
+ $retryInfo.textContent = `Retry ${attempt}...`;
684
  }
685
+ }
686
 
687
+ function onReconnect() {
688
+ console.log('Reconnected!');
689
+ reconnectAttempts = 0;
690
+ $retryInfo.textContent = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
  }
692
 
693
+ function updateConnectionUI(status, text) {
694
+ $connStatus.className = 'connection-box ' + status;
695
+ $connText.textContent = text;
696
+ }
697
 
698
+ // ============= GAME EVENTS =============
699
+ function onAck(data) {
700
+ myId = data.id;
701
+ serverTimeDiff = Date.now() - data.t;
702
+ console.log('Ack received, time diff:', serverTimeDiff);
703
+ }
704
 
705
+ function onInit(data) {
706
+ console.log('Init received');
707
+ hasJoined = true;
708
+ $login.style.display = 'none';
709
+
710
+ // Clear existing
711
+ lanes.forEach(l => scene.remove(l.mesh));
712
+ lanes.clear();
713
+ otherPlayers.forEach(p => {
714
+ scene.remove(p.mesh);
715
+ removeLabel(p.id);
716
  });
717
+ otherPlayers.clear();
718
+
719
+ // Create lanes
720
+ data.ls.forEach(l => createLane(l));
721
 
722
  // Create player
723
  createPlayer();
724
+ resetPlayerState(data.p);
725
 
726
  // Create other players
727
+ data.ps.forEach(p => createOtherPlayer(p));
 
 
 
 
728
 
729
+ // Update UI
730
+ onLeaderboard(data.lb);
731
  updatePlayerCount();
732
+
733
+ serverTimeDiff = Date.now() - data.t;
734
  }
735
 
736
+ function onPlayerJoin(data) {
737
+ if (data.id !== myId) {
738
+ createOtherPlayer(data);
739
  updatePlayerCount();
740
  }
741
  }
742
 
743
+ function onPlayerLeave(id) {
744
+ const other = otherPlayers.get(id);
745
  if (other) {
746
  scene.remove(other.mesh);
747
+ removeLabel(id);
748
+ otherPlayers.delete(id);
749
  updatePlayerCount();
750
  }
751
  }
752
 
753
+ function onPlayerMove(data) {
754
  const other = otherPlayers.get(data.id);
755
  if (other) {
756
+ const serverTime = data.t - serverTimeDiff;
757
+ other.buffer.push({
758
+ t: serverTime,
759
+ x: data.wx,
760
+ z: data.wz,
761
+ r: data.r,
762
+ sx: data.hs.x,
763
+ sz: data.hs.z,
764
+ tx: data.ht.x,
765
+ tz: data.ht.z
766
  });
767
+ // Trim buffer
768
+ while (other.buffer.length > 30) {
769
+ other.buffer.shift();
 
 
 
 
770
  }
771
  }
772
  }
773
 
774
+ function onPlayerDie(data) {
775
+ if (data.id === myId) {
776
+ gameOver(data.type === 'water');
777
  } else {
778
  const other = otherPlayers.get(data.id);
779
  if (other) {
 
787
  }
788
  }
789
 
790
+ function onPlayerRespawn(data) {
791
  const other = otherPlayers.get(data.id);
792
  if (other) {
793
  other.alive = true;
794
  other.mesh.position.set(0, 0, 0);
795
  other.mesh.scale.set(1, 1, 1);
796
+ other.buffer = [];
 
 
797
  }
798
  }
799
 
800
+ function onMoveConfirm(data) {
801
+ if (data.sc > score) {
802
+ score = data.sc;
803
+ $score.textContent = score;
 
 
 
 
804
  }
805
+ serverTimeDiff = Date.now() - data.t;
806
  }
807
 
808
+ function onMoveReject(data) {
809
+ console.log('Move rejected:', data.r);
 
 
 
 
810
  }
811
 
812
+ function onGameState(data) {
813
+ // Update obstacles
814
+ Object.entries(data.obs).forEach(([idx, obstacles]) => {
815
+ const lane = lanes.get(parseInt(idx));
816
+ if (lane) {
817
+ updateObstacles(lane, obstacles);
818
+ }
819
+ });
820
+
821
+ // Update other players
822
+ data.ps.forEach(p => {
823
+ if (p.id !== myId) {
824
  const other = otherPlayers.get(p.id);
825
  if (other) {
826
  other.serverState = p;
827
+ other.alive = p.a === 1;
828
+ other.score = p.s;
829
  }
830
  }
831
  });
832
 
833
+ $playersOnline.textContent = data.ps.length + ' online';
834
+ serverTimeDiff = Date.now() - data.t;
 
 
 
 
 
 
 
835
  }
836
 
837
+ function onNewLane(data) {
838
+ createLane(data);
839
  }
840
 
841
+ function onLeaderboard(data) {
842
+ $lbEntries.innerHTML = '';
843
+ data.forEach((e, i) => {
 
 
844
  const div = document.createElement('div');
845
+ div.className = 'lb-entry ' + ['gold', 'silver', 'bronze'][i];
846
+ div.innerHTML = `<span class="lb-name">${escapeHtml(e.n)}</span><span>${e.s}</span>`;
847
+ $lbEntries.appendChild(div);
 
 
 
 
848
  });
849
  }
850
 
851
+ function onPong(data) {
852
+ ping = Date.now() - data.c;
853
+ serverTimeDiff = Date.now() - data.s - ping / 2;
854
+
855
+ $pingDisplay.textContent = ping + 'ms';
856
+
857
+ // Update connection quality indicator
858
+ if (ping < 100) {
859
+ $connDot.className = 'stat-dot good';
860
+ } else if (ping < 250) {
861
+ $connDot.className = 'stat-dot medium';
862
+ } else {
863
+ $connDot.className = 'stat-dot poor';
864
+ }
865
+ }
866
+
867
+ function onRespawned(data) {
868
+ resetPlayerState(data);
869
+ isGameOver = false;
870
+ $gameOver.classList.remove('visible');
871
+ }
872
+
873
+ function onError(data) {
874
+ console.error('Server error:', data.m);
875
  }
876
 
877
  function escapeHtml(text) {
 
880
  return div.innerHTML;
881
  }
882
 
883
+ // ============= GAME OBJECTS =============
884
+ function createVoxel(w, h, d, color, x = 0, y = 0, z = 0) {
885
+ const geo = new THREE.BoxGeometry(w, h, d);
886
+ const mat = new THREE.MeshLambertMaterial({ color });
887
+ const mesh = new THREE.Mesh(geo, mat);
888
+ mesh.position.set(x, y, z);
889
+ mesh.castShadow = true;
890
+ mesh.receiveShadow = true;
891
+ return mesh;
892
+ }
893
+
894
+ function createPlayerMesh(bodyColor = COLORS.penguin) {
895
+ const group = new THREE.Group();
896
+ group.add(createVoxel(7, 9, 7, bodyColor, 0, 4.5, 0));
897
+ group.add(createVoxel(5, 6, 2, COLORS.belly, 0, 4, 3));
898
+ group.add(createVoxel(3, 2, 3, COLORS.beak, 0, 7.5, 3));
899
+ group.add(createVoxel(2.5, 2, 2.5, COLORS.beak, -2, 1, 1));
900
+ group.add(createVoxel(2.5, 2, 2.5, COLORS.beak, 2, 1, 1));
901
+ return group;
902
+ }
903
+
904
+ function createPlayer() {
905
+ if (player) scene.remove(player);
906
+ player = createPlayerMesh();
907
+ scene.add(player);
908
+ }
909
+
910
+ function createOtherPlayer(data) {
911
+ if (otherPlayers.has(data.id)) return;
912
+
913
+ const mesh = createPlayerMesh(COLORS.otherPlayer);
914
+ mesh.position.set(data.wx, 0, data.wz);
915
+ scene.add(mesh);
916
+
917
+ otherPlayers.set(data.id, {
918
+ id: data.id,
919
+ name: data.n,
920
+ mesh,
921
+ buffer: [],
922
+ alive: data.a === 1,
923
+ score: data.s,
924
+ lastX: data.wx,
925
+ lastZ: data.wz
926
+ });
927
+
928
+ createLabel(data.id, data.n);
929
+ }
930
+
931
+ function createLabel(id, name) {
932
+ const label = document.createElement('div');
933
+ label.className = 'player-label';
934
+ label.id = 'label-' + id;
935
+ label.textContent = name;
936
+ $labelsContainer.appendChild(label);
937
  }
938
 
939
+ function removeLabel(id) {
940
+ const label = document.getElementById('label-' + id);
941
+ if (label) label.remove();
942
+ }
943
+
944
+ function createLane(data) {
945
+ if (lanes.has(data.i)) return;
946
+
947
  const lane = {
948
+ index: data.i,
949
+ type: data.t,
950
+ staticObs: data.so || [],
951
+ speed: data.sp,
952
+ dir: data.d,
953
  mesh: new THREE.Group(),
954
  obstacles: [],
955
+ obsMap: new Map()
956
+ };
957
+
958
+ const typeColors = {
959
+ 'g': [data.i % 2 === 0 ? COLORS.grass : COLORS.grassLight, -5],
960
+ 'r': [COLORS.road, -5],
961
+ 'w': [COLORS.water, -13]
962
  };
963
 
964
+ const [color, yPos] = typeColors[data.t];
965
+ const ground = createVoxel(CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE + 200, CONFIG.TILE_SIZE, CONFIG.TILE_SIZE, color, 0, yPos, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  lane.mesh.add(ground);
967
 
968
+ if (data.t === 'r') {
969
+ lane.mesh.add(createVoxel(CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE, 1, 2, COLORS.roadLine, 0, yPos + 0.6, 0));
 
970
  }
971
 
972
+ // Trees
973
+ if (data.t === 'g' && lane.staticObs) {
974
+ lane.staticObs.forEach(gx => {
 
 
975
  const tree = new THREE.Group();
976
  tree.add(createVoxel(3, 5, 3, COLORS.treeTrunk, 0, 2.5, 0));
977
  tree.add(createVoxel(9, 9, 9, COLORS.treeLeaves, 0, 7, 0));
978
+ tree.position.x = gx * CONFIG.TILE_SIZE;
979
  lane.mesh.add(tree);
980
  });
981
  }
982
 
983
+ lane.mesh.position.z = data.i * CONFIG.TILE_SIZE;
984
  scene.add(lane.mesh);
985
+ lanes.set(data.i, lane);
986
 
987
+ // Create obstacles
988
+ if (data.obs) {
989
+ data.obs.forEach(o => addObstacle(lane, o));
 
 
990
  }
991
  }
992
 
993
+ function addObstacle(lane, data) {
994
+ const mesh = new THREE.Group();
995
+
996
+ if (data.l) { // Log
997
+ mesh.add(createVoxel(data.w, 2, 7, COLORS.log, 0, -1, 0));
998
+ } else { // Car
 
 
 
 
 
 
999
  const color = Math.random() > 0.5 ? COLORS.carRed : COLORS.carBlue;
1000
+ mesh.add(createVoxel(12, 7, 7, color, 0, 3.5, 0));
1001
+ mesh.add(createVoxel(8, 3, 5, 0xFFFFFF, 0, 7, 0));
1002
  }
1003
 
1004
+ mesh.position.x = data.x;
1005
+ lane.mesh.add(mesh);
1006
+
1007
+ const obs = { id: data.id, mesh, isLog: data.l, width: data.w, x: data.x, targetX: data.x };
1008
  lane.obstacles.push(obs);
1009
+ lane.obsMap.set(data.id, obs);
1010
  }
1011
 
1012
+ function updateObstacles(lane, serverObs) {
1013
+ const serverIds = new Set(serverObs.map(o => o.id));
1014
 
1015
+ // Remove old
1016
  for (let i = lane.obstacles.length - 1; i >= 0; i--) {
1017
  const obs = lane.obstacles[i];
1018
  if (!serverIds.has(obs.id)) {
1019
  lane.mesh.remove(obs.mesh);
1020
+ lane.obsMap.delete(obs.id);
1021
  lane.obstacles.splice(i, 1);
1022
  }
1023
  }
1024
 
1025
+ // Update/add
1026
+ serverObs.forEach(so => {
1027
+ let obs = lane.obsMap.get(so.id);
1028
  if (obs) {
1029
+ obs.targetX = so.x;
 
1030
  } else {
1031
+ // Need full data from a different source
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  }
1033
  });
1034
  }
1035
 
1036
+ // ============= PLAYER STATE =============
1037
+ function resetPlayerState(data) {
1038
+ playerGX = data.gx;
1039
+ playerGZ = data.gz;
1040
+ playerWX = data.wx;
1041
+ playerWZ = data.wz;
1042
+ score = data.s;
1043
+ maxScore = data.s;
1044
  isGameOver = false;
 
 
 
 
 
 
 
 
 
 
 
1045
  isHopping = false;
1046
+ attachedLogId = null;
1047
  moveQueue = [];
 
 
 
 
1048
 
1049
+ player.position.set(playerWX, 0, playerWZ);
1050
+ player.rotation.y = data.r || 0;
1051
+ player.scale.set(1, 1, 1);
1052
+
1053
+ $score.textContent = score;
1054
+ $tutorial.style.display = 'block';
1055
+ $gameOver.classList.remove('visible');
1056
+
1057
+ camera.position.set(playerWX - 100, 100, playerWZ + 100);
1058
  }
1059
 
1060
+ function updatePlayerCount() {
1061
+ const count = otherPlayers.size + 1;
1062
+ $playerCount.textContent = 'Players: ' + count;
1063
  }
1064
 
1065
+ // ============= MOVEMENT =============
1066
  function attemptMove(dx, dz) {
1067
+ if (isGameOver || !hasJoined) return;
1068
+ if (dz < 0) return; // No backward
 
 
 
1069
 
1070
  const now = Date.now();
1071
 
1072
+ // Rate limit
1073
+ if (now - lastMoveTime < CONFIG.MOVE_COOLDOWN) {
1074
+ if (moveQueue.length < CONFIG.MAX_QUEUE) {
1075
+ moveQueue.push({ dx, dz });
 
1076
  }
1077
  return;
1078
  }
1079
 
1080
  if (isHopping) {
1081
+ if (moveQueue.length < CONFIG.MAX_QUEUE) {
1082
+ moveQueue.push({ dx, dz });
1083
  }
1084
  return;
1085
  }
1086
 
1087
+ const tx = playerGX + dx;
1088
+ const tz = playerGZ + dz;
1089
 
1090
  // Bounds check
1091
+ if (Math.abs(tx) > Math.floor(CONFIG.GRID_WIDTH / 2)) return;
1092
 
1093
+ // Tree check
1094
+ const lane = lanes.get(tz);
1095
+ if (lane && lane.type === 'g' && lane.staticObs.includes(tx)) return;
 
 
1096
 
1097
+ // Execute locally
1098
  lastMoveTime = now;
1099
  isHopping = true;
1100
  hopStartTime = now;
1101
+ hopStartPos = { x: playerWX, z: playerWZ };
1102
+ hopTargetPos = { x: tx * CONFIG.TILE_SIZE, z: tz * CONFIG.TILE_SIZE };
1103
+ playerGX = tx;
1104
+ playerGZ = tz;
1105
+ playerWX = tx * CONFIG.TILE_SIZE;
1106
+ playerWZ = tz * CONFIG.TILE_SIZE;
1107
+ attachedLogId = null;
1108
+
1109
+ // Rotation
1110
  if (dx === 1) player.rotation.y = -Math.PI / 2;
1111
  else if (dx === -1) player.rotation.y = Math.PI / 2;
1112
  else if (dz === 1) player.rotation.y = 0;
1113
  else if (dz === -1) player.rotation.y = Math.PI;
1114
 
1115
+ // Score
1116
+ if (tz > maxScore) {
1117
+ maxScore = tz;
1118
+ score = maxScore;
1119
+ $score.textContent = score;
 
 
 
 
 
 
1120
  }
1121
 
1122
  // Send to server
1123
+ socket.emit('m', { dx, dz, s: ++moveSeq });
 
 
1124
 
1125
+ $tutorial.style.display = 'none';
1126
  }
1127
 
1128
  function processHop() {
 
1130
 
1131
  const now = Date.now();
1132
  const elapsed = now - hopStartTime;
1133
+ const t = Math.min(elapsed / CONFIG.HOP_DURATION, 1);
1134
 
1135
+ player.position.x = hopStartPos.x + (hopTargetPos.x - hopStartPos.x) * t;
1136
+ player.position.z = hopStartPos.z + (hopTargetPos.z - hopStartPos.z) * t;
1137
+ player.position.y = Math.sin(t * Math.PI) * 9;
1138
 
1139
+ // Squash/stretch
1140
+ player.scale.set(t < 1 ? 0.9 : 1, t < 1 ? 1.1 : 1, t < 1 ? 0.9 : 1);
 
 
1141
 
1142
+ // Mid-hop collision
1143
+ checkMidHopCollision(t);
1144
 
1145
+ if (t >= 1) {
1146
  isHopping = false;
1147
+ player.position.set(hopTargetPos.x, 0, hopTargetPos.z);
 
 
 
1148
  player.scale.set(1.3, 0.7, 1.3);
1149
+ setTimeout(() => player && player.scale.set(1, 1, 1), 60);
 
 
1150
 
1151
+ checkLandingCollision();
 
1152
 
1153
+ // Process queue
1154
  if (moveQueue.length > 0 && !isGameOver) {
1155
  const next = moveQueue.shift();
1156
  setTimeout(() => attemptMove(next.dx, next.dz), 10);
 
1158
  }
1159
  }
1160
 
1161
+ function checkMidHopCollision(t) {
1162
  if (isGameOver) return;
1163
 
1164
+ const cx = hopStartPos.x + (hopTargetPos.x - hopStartPos.x) * t;
1165
+ const cz = hopStartPos.z + (hopTargetPos.z - hopStartPos.z) * t;
1166
+ const cgz = Math.round(cz / CONFIG.TILE_SIZE);
 
1167
 
1168
+ const lane = lanes.get(cgz);
1169
+ if (!lane || lane.type !== 'r') return;
1170
 
 
1171
  for (const obs of lane.obstacles) {
1172
  if (obs.isLog) continue;
1173
+ if (Math.abs(cx - obs.mesh.position.x) < (obs.width / 2 + 3)) {
1174
+ gameOver(false);
 
 
 
 
1175
  return;
1176
  }
1177
  }
1178
  }
1179
 
1180
+ function checkLandingCollision() {
1181
  if (isGameOver) return;
1182
 
1183
+ const lane = lanes.get(playerGZ);
1184
+ if (!lane || lane.type === 'g') return;
 
 
1185
 
1186
+ // Bounds
1187
+ if (Math.abs(player.position.x) > (CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE / 2 + 10)) {
1188
+ gameOver(false);
1189
  return;
1190
  }
1191
 
1192
  let onLog = false;
1193
 
1194
  for (const obs of lane.obstacles) {
1195
+ const ox = obs.mesh.position.x;
1196
 
1197
  if (obs.isLog) {
1198
+ if (Math.abs(player.position.x - ox) < (obs.width / 2 + 4)) {
 
 
 
1199
  onLog = true;
1200
+ attachedLogId = obs.id;
 
 
 
1201
  }
1202
  } else {
1203
+ if (Math.abs(player.position.x - ox) < (obs.width / 2 + 3)) {
1204
+ gameOver(false);
 
 
1205
  return;
1206
  }
1207
  }
1208
  }
1209
 
1210
+ if (lane.type === 'w' && !onLog) {
1211
+ gameOver(true);
1212
+ } else if (lane.type !== 'w') {
1213
+ attachedLogId = null;
1214
  }
1215
  }
1216
 
1217
+ function gameOver(drown = false) {
1218
  if (isGameOver) return;
1219
  isGameOver = true;
1220
  isHopping = false;
1221
  moveQueue = [];
1222
+
 
 
1223
  if (drown) {
1224
  player.position.y = -10;
1225
  } else {
1226
  player.scale.set(1.5, 0.1, 1.5);
1227
  }
1228
+
1229
+ $finalScore.textContent = 'Score: ' + score;
1230
+ $gameOver.classList.add('visible');
1231
+ }
1232
+
1233
+ function respawn() {
1234
+ if (!socket || !socket.connected) return;
1235
+ socket.emit('rs');
1236
  }
1237
 
1238
+ // ============= UPDATE LOOP =============
1239
+ function updateObstaclePositions() {
1240
  lanes.forEach(lane => {
1241
  lane.obstacles.forEach(obs => {
1242
  if (obs.targetX !== undefined) {
 
1243
  const diff = obs.targetX - obs.mesh.position.x;
1244
+ obs.mesh.position.x += diff * 0.2;
1245
  obs.x = obs.mesh.position.x;
1246
  }
1247
  });
1248
  });
1249
  }
1250
 
1251
+ function updateLogRiding() {
1252
+ if (isHopping || attachedLogId === null || isGameOver) return;
1253
+
1254
+ const lane = lanes.get(playerGZ);
1255
+ if (!lane) return;
1256
+
1257
+ const log = lane.obstacles.find(o => o.id === attachedLogId);
1258
+ if (log) {
1259
+ player.position.x = log.mesh.position.x;
1260
+ playerWX = player.position.x;
1261
+ playerGX = Math.round(playerWX / CONFIG.TILE_SIZE);
1262
+
1263
+ if (Math.abs(playerWX) > (CONFIG.GRID_WIDTH * CONFIG.TILE_SIZE / 2 + 10)) {
1264
+ gameOver(true);
1265
+ }
1266
+ } else {
1267
+ attachedLogId = null;
1268
+ if (lane.type === 'w') {
1269
+ checkLandingCollision();
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
  function updateOtherPlayers() {
1275
+ const renderTime = Date.now() - CONFIG.INTERP_DELAY;
1276
 
1277
+ otherPlayers.forEach(other => {
1278
  if (!other.alive) return;
1279
 
1280
+ const buffer = other.buffer;
1281
+
1282
  if (buffer.length >= 2) {
 
1283
  let i = 0;
1284
+ while (i < buffer.length - 1 && buffer[i + 1].t <= renderTime) i++;
 
 
1285
 
1286
  if (i < buffer.length - 1) {
1287
  const p1 = buffer[i];
1288
  const p2 = buffer[i + 1];
1289
+ const t = Math.max(0, Math.min(1, (renderTime - p1.t) / (p2.t - p1.t)));
1290
+
1291
+ // Hop interpolation
1292
+ other.mesh.position.x = p1.x + (p2.x - p1.x) * t;
1293
+ other.mesh.position.z = p1.z + (p2.z - p1.z) * t;
1294
+ other.mesh.position.y = Math.sin(t * Math.PI) * 9;
1295
+ other.mesh.rotation.y = p2.r;
1296
+ other.mesh.scale.set(t < 1 ? 0.9 : 1, t < 1 ? 1.1 : 1, t < 1 ? 0.9 : 1);
 
 
 
 
 
 
 
1297
  } else if (buffer.length > 0) {
1298
+ const last = buffer[buffer.length - 1];
1299
+ other.mesh.position.set(last.x, 0, last.z);
1300
+ other.mesh.rotation.y = last.r;
 
 
 
1301
  other.mesh.scale.set(1, 1, 1);
1302
  }
1303
 
1304
+ while (buffer.length > 2 && buffer[1].t < renderTime) buffer.shift();
 
 
 
 
 
 
 
 
1305
  }
1306
  });
1307
  }
1308
 
1309
+ function updateLabels() {
1310
+ otherPlayers.forEach((other, id) => {
1311
+ const label = document.getElementById('label-' + id);
1312
+ if (!label) return;
1313
 
1314
+ if (!other.alive) {
1315
+ label.style.display = 'none';
1316
+ return;
1317
+ }
1318
 
1319
+ const pos = other.mesh.position.clone();
1320
+ pos.y += 18;
 
 
 
1321
 
1322
+ const vector = pos.project(camera);
1323
+
1324
+ if (vector.z < 1 && vector.z > -1) {
1325
+ const x = (vector.x * 0.5 + 0.5) * window.innerWidth;
1326
+ const y = (-vector.y * 0.5 + 0.5) * window.innerHeight;
1327
+ label.style.left = x + 'px';
1328
+ label.style.top = (y - 15) + 'px';
1329
+ label.style.display = 'block';
1330
+ } else {
1331
+ label.style.display = 'none';
1332
  }
1333
+ });
1334
+ }
1335
+
1336
+ function updateCamera() {
1337
+ if (isGameOver || !player) return;
1338
+
1339
+ const tx = player.position.x - 100;
1340
+ const tz = player.position.z + 100;
1341
+ camera.position.x += (tx - camera.position.x) * 0.05;
1342
+ camera.position.z += (tz - camera.position.z) * 0.1;
1343
  }
1344
 
1345
+ // ============= RENDER LOOP =============
1346
  function animate() {
1347
  requestAnimationFrame(animate);
1348
 
1349
  processHop();
1350
+ updateObstaclePositions();
 
1351
  updateLogRiding();
1352
+ updateOtherPlayers();
1353
+ updateCamera();
1354
+ updateLabels();
 
 
 
 
 
 
 
1355
 
 
1356
  renderer.render(scene, camera);
1357
  }
1358
 
 
1367
  renderer.setSize(window.innerWidth, window.innerHeight);
1368
  }
1369
 
1370
+ // ============= INPUT =============
1371
+ function initInputs() {
1372
+ const container = document.getElementById('game-container');
1373
+
1374
+ // Touch
1375
+ container.addEventListener('touchstart', e => {
1376
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
1377
+ touchStartX = e.touches[0].clientX;
1378
+ touchStartY = e.touches[0].clientY;
1379
+ touchStartTime = Date.now();
1380
+ }, { passive: true });
1381
+
1382
+ container.addEventListener('touchend', e => {
1383
+ if (isGameOver || !hasJoined) return;
1384
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
1385
+ e.preventDefault();
1386
 
1387
+ const dx = e.changedTouches[0].clientX - touchStartX;
1388
+ const dy = e.changedTouches[0].clientY - touchStartY;
1389
+ handleSwipe(dx, dy);
1390
+ }, { passive: false });
1391
+
1392
+ container.addEventListener('touchmove', e => {
1393
+ if (e.target.tagName !== 'INPUT') e.preventDefault();
1394
+ }, { passive: false });
1395
+
1396
+ // Mouse
1397
+ let mouseX, mouseY;
1398
+ container.addEventListener('mousedown', e => {
1399
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
1400
+ mouseX = e.clientX;
1401
+ mouseY = e.clientY;
1402
+ });
1403
 
1404
+ container.addEventListener('mouseup', e => {
1405
+ if (isGameOver || !hasJoined) return;
1406
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
1407
+ handleSwipe(e.clientX - mouseX, e.clientY - mouseY);
 
 
 
 
 
 
 
 
 
 
1408
  });
1409
 
1410
+ // Keyboard
1411
  document.addEventListener('keydown', e => {
1412
+ if (e.target.tagName === 'INPUT') return;
1413
+
1414
+ switch(e.key) {
1415
+ case 'ArrowUp': case 'w': case 'W': attemptMove(0, 1); break;
1416
+ case 'ArrowDown': case 's': case 'S': attemptMove(0, -1); break;
1417
+ case 'ArrowLeft': case 'a': case 'A': attemptMove(1, 0); break;
1418
+ case 'ArrowRight': case 'd': case 'D': attemptMove(-1, 0); break;
1419
+ }
1420
  });
1421
 
1422
+ // Login
1423
+ $nameInput.addEventListener('input', () => {
1424
+ $joinBtn.disabled = !$nameInput.value.trim() || !isConnected;
1425
+ });
1426
+
1427
+ $nameInput.addEventListener('keypress', e => {
1428
+ if (e.key === 'Enter' && !$joinBtn.disabled) {
1429
+ joinGame();
1430
+ }
1431
+ });
1432
+
1433
+ $joinBtn.addEventListener('click', joinGame);
1434
+ $restartBtn.addEventListener('click', respawn);
1435
  }
1436
 
1437
  function handleSwipe(dx, dy) {
1438
+ if (Math.abs(dx) < 10 && Math.abs(dy) < 10) {
1439
+ attemptMove(0, 1);
1440
+ return;
1441
  }
1442
+
1443
  if (Math.abs(dx) > Math.abs(dy)) {
1444
+ attemptMove(dx > 0 ? 1 : -1, 0);
 
1445
  } else {
1446
+ attemptMove(0, dy < 0 ? 1 : -1);
 
1447
  }
1448
  }
1449
 
1450
+ function joinGame() {
1451
+ const name = $nameInput.value.trim();
1452
+ if (!name || !socket || !socket.connected) return;
1453
+
1454
+ $joinBtn.disabled = true;
1455
+ $joinBtnText.innerHTML = '<span class="spinner" style="width:16px;height:16px;display:inline-block;vertical-align:middle;"></span>';
1456
+
1457
+ socket.emit('join', { name });
1458
+ }
1459
+
1460
+ // Start
1461
  init();
1462
+ })();
1463
  </script>
1464
  </body>
1465
  </html>