OrbitMC commited on
Commit
cc67353
·
verified ·
1 Parent(s): 14796db

Update public/index.html

Browse files
Files changed (1) hide show
  1. public/index.html +365 -687
public/index.html CHANGED
@@ -2,732 +2,410 @@
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, minimal-ui">
6
- <title>Ascent: Multiplayer</title>
7
  <style>
8
- /* RESET & LAYOUT */
9
- * { margin: 0; padding: 0; box-sizing: border-box; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }
10
- body { background-color: #000; overflow: hidden; width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
11
 
12
- /* GAME CONTAINER */
13
- #game-container { position: relative; width: 100%; height: 100%; max-width: 50vh; max-height: 100vh; background: linear-gradient(180deg, #2d2347 0%, #5d426e 100%); overflow: hidden; box-shadow: 0 0 20px rgba(0,0,0,0.5); }
14
- canvas { display: block; width: 100%; height: 100%; }
15
-
16
- /* UI OVERLAY */
17
- #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
18
- #score-container { position: absolute; top: 60px; right: 20px; text-align: right; }
19
- #score { color: rgba(255, 255, 255, 0.9); font-size: 32px; font-weight: 900; text-shadow: 2px 2px 0px rgba(0,0,0,0.3); line-height: 1; }
20
- #score-label { font-size: 14px; color: rgba(255,255,255,0.7); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 2px; }
21
- #star-count { margin-top: 5px; font-size: 16px; color: #ffd700; font-weight: bold; text-shadow: 1px 1px 0 rgba(0,0,0,0.5); }
22
 
23
- /* Powerups */
24
- #powerups { position: absolute; top: 130px; right: 20px; display: flex; flex-direction: column; gap: 5px; align-items: flex-end; }
25
- .powerup-badge { background: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 15px; color: #fff; font-size: 12px; font-weight: bold; display: none; }
26
- #tutorial { position: absolute; top: 40%; width: 100%; text-align: center; color: rgba(255,255,255,0.6); font-size: 18px; font-weight: 600; animation: pulse 2s infinite; }
27
 
28
- /* MENUS */
29
- .overlay-menu { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(18, 14, 30, 0.95); flex-direction: column; justify-content: center; align-items: center; pointer-events: auto; z-index: 50; }
30
- .overlay-menu h1 { color: #fff; font-size: 40px; margin-bottom: 20px; text-transform: uppercase; font-style: italic; }
31
- .overlay-menu p { color: #ccc; margin-bottom: 30px; }
32
- .btn { background: #fff; color: #2d2347; padding: 15px 40px; font-size: 20px; font-weight: bold; border-radius: 30px; border: none; cursor: pointer; text-transform: uppercase; box-shadow: 0 5px 15px rgba(0,0,0,0.3); transition: transform 0.1s; margin-top: 10px; }
33
- .btn:active { transform: scale(0.95); }
34
-
35
- /* LOGIN SCREEN */
36
- #login-screen { display: flex; z-index: 100; }
37
- #username-input { padding: 15px; font-size: 18px; border-radius: 10px; border: none; margin-bottom: 20px; text-align: center; width: 80%; max-width: 300px; font-family: inherit; }
38
-
39
- /* LEADERBOARD - Compact Top Left */
40
- #leaderboard {
41
- position: absolute; top: 15px; left: 15px;
42
- background: rgba(0,0,0,0.4);
43
- padding: 8px 10px; border-radius: 8px;
44
- color: white; font-size: 11px; width: 120px;
45
- pointer-events: none; backdrop-filter: blur(2px);
46
  }
47
- .lb-title { color:#aaa; font-size:9px; margin-bottom:4px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: bold;}
48
- .lb-row { display: flex; justify-content: space-between; margin-bottom: 3px; align-items: center; }
49
- .lb-rank { color: #ffd700; font-weight: bold; margin-right: 4px; width: 12px; }
50
- .lb-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 55px; }
51
-
52
- @keyframes pulse { 0% { opacity: 0.4; transform: translateY(0); } 50% { opacity: 0.8; transform: translateY(-5px); } 100% { opacity: 0.4; transform: translateY(0); } }
53
- </style>
54
- </head>
55
- <body>
56
-
57
- <div id="game-container">
58
- <canvas id="gameCanvas"></canvas>
59
 
60
- <div id="ui-layer">
61
- <div id="leaderboard">
62
- <div class="lb-title">Top Altitude</div>
63
- <div id="lb-content">Connecting...</div>
64
- </div>
65
-
66
- <div id="score-container">
67
- <div id="score-label">Height</div>
68
- <div id="score"><span id="score-value">0</span>m</div>
69
- <div id="star-count">★ <span id="stars-value">0</span></div>
70
- </div>
71
-
72
- <div id="powerups">
73
- <div id="badge-shield" class="powerup-badge" style="color:#00ffff">🛡️ SAFE</div>
74
- <div id="badge-rocket" class="powerup-badge" style="color:#ff4444">🚀 BOOST</div>
75
- <div id="badge-slow" class="powerup-badge" style="color:#ffff00">⏳ SLOW</div>
76
- </div>
77
-
78
- <div id="tutorial">DRAG TO JUMP</div>
79
-
80
- <!-- LOGIN SCREEN -->
81
- <div id="login-screen" class="overlay-menu">
82
- <h1>Ascent MP</h1>
83
- <input type="text" id="username-input" placeholder="Enter Username" maxlength="12">
84
- <button class="btn" id="start-btn">PLAY</button>
85
- </div>
86
-
87
- <!-- GAME OVER MENU -->
88
- <div id="game-over" class="overlay-menu">
89
- <h1>Fell Down!</h1>
90
- <p>Best Height: <span id="final-score">0</span>m</p>
91
- <button class="btn" id="retry-btn">Try Again</button>
92
- </div>
93
- </div>
94
- </div>
95
-
96
- <script src="/socket.io/socket.io.js"></script>
97
- <script>
98
- // --- SOCKET & NETWORK ---
99
- const socket = io();
100
- let myId = null;
101
- let serverSeed = 0;
102
- let otherPlayers = {}; // Map of id -> Ghost Object
103
- let leaderboardData = [];
104
-
105
- // Setup Login
106
- const loginScreen = document.getElementById('login-screen');
107
- const usernameInput = document.getElementById('username-input');
108
- const startBtn = document.getElementById('start-btn');
109
- const lbContent = document.getElementById('lb-content');
110
-
111
- let myName = localStorage.getItem('ascent_name') || `Jumper${Math.floor(Math.random()*100)}`;
112
- usernameInput.value = myName;
113
-
114
- startBtn.onclick = () => {
115
- const name = usernameInput.value.trim() || "Anon";
116
- localStorage.setItem('ascent_name', name);
117
- myName = name;
118
- loginScreen.style.display = 'none';
119
-
120
- // Join Server
121
- socket.emit('join', { name: myName, skin: state.currentSkin });
122
- initLevel(); // Now safe to init level as player is "logged in"
123
- state.isPlaying = true;
124
- gameLoop();
125
- };
126
-
127
- socket.on('connect', () => { myId = socket.id; });
128
- socket.on('config', (data) => { serverSeed = data.seed; });
129
-
130
- // World Update (Optimized 'w' event)
131
- socket.on('w', (data) => {
132
- // Sync Ghosts
133
- const currentIds = [];
134
-
135
- data.p.forEach(pData => {
136
- // [id, x, y, skin, isDead, name]
137
- const pid = pData[0];
138
- currentIds.push(pid);
139
-
140
- if (pid !== socket.id) {
141
- if (!otherPlayers[pid]) {
142
- otherPlayers[pid] = {
143
- x: pData[1], y: pData[2],
144
- tx: pData[1], ty: pData[2],
145
- skin: pData[3], isDead: pData[4], name: pData[5]
146
- };
147
- } else {
148
- const op = otherPlayers[pid];
149
- op.tx = pData[1];
150
- op.ty = pData[2];
151
- op.skin = pData[3];
152
- op.isDead = pData[4];
153
- op.name = pData[5];
154
- }
155
  }
156
- });
157
-
158
- // Cleanup disconnected
159
- for (let id in otherPlayers) {
160
- if (!currentIds.includes(id)) delete otherPlayers[id];
161
- }
162
-
163
- // Leaderboard
164
- leaderboardData = data.l;
165
- updateLeaderboardUI();
166
- });
167
-
168
- function updateLeaderboardUI() {
169
- let html = '';
170
- leaderboardData.forEach((p, i) => {
171
- html += `<div class="lb-row">
172
- <div><span class="lb-rank">#${i+1}</span><span class="lb-name">${p.name}</span></div>
173
- <span>${p.score}m</span>
174
- </div>`;
175
- });
176
- lbContent.innerHTML = html;
177
- }
178
-
179
- // Network Loop: 20 TPS (50ms) is enough for client upload. Prevents network flooding.
180
- setInterval(() => {
181
- if (state.isPlaying || state.gameOver) {
182
- // Send Array [x, y, vx, vy, score, isDead]
183
- socket.emit('update', [
184
- Math.round(player.x),
185
- Math.round(player.y),
186
- parseFloat(player.vx.toFixed(2)),
187
- parseFloat(player.vy.toFixed(2)),
188
- state.highScore,
189
- state.gameOver ? 1 : 0
190
- ]);
191
- }
192
- }, 50);
193
-
194
- // --- DETERMINISTIC RNG (FIXED LEVEL CONSISTENCY) ---
195
- // We use 'state.platformCount' instead of 'y' to seed the random.
196
- // This guarantees the sequence of platforms is identical every time.
197
- function getSeededRandom(offset) {
198
- const seed = serverSeed + (state.platformCount * 1337) + offset;
199
- const x = Math.sin(seed) * 10000;
200
- return x - Math.floor(x);
201
- }
202
-
203
- // --- AUDIO SYSTEM ---
204
- const AudioSys = {
205
- ctx: null,
206
- init: function() {
207
- window.AudioContext = window.AudioContext || window.webkitAudioContext;
208
- this.ctx = new AudioContext();
209
- },
210
- playTone: function(freq, type, duration, vol = 0.1) {
211
- if (!this.ctx) return;
212
- const osc = this.ctx.createOscillator();
213
- const gain = this.ctx.createGain();
214
- osc.type = type;
215
- osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
216
- gain.gain.setValueAtTime(vol, this.ctx.currentTime);
217
- gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
218
- osc.connect(gain);
219
- gain.connect(this.ctx.destination);
220
- osc.start();
221
- osc.stop(this.ctx.currentTime + duration);
222
- },
223
- sfx: {
224
- jump: () => AudioSys.playTone(400, 'sine', 0.1, 0.1),
225
- land: () => AudioSys.playTone(150, 'triangle', 0.1, 0.2),
226
- coin: () => { AudioSys.playTone(1200, 'sine', 0.1, 0.05); setTimeout(() => AudioSys.playTone(1600, 'sine', 0.2, 0.05), 50); },
227
- crumble: () => AudioSys.playTone(100, 'sawtooth', 0.3, 0.1),
228
- die: () => AudioSys.playTone(100, 'sawtooth', 1.0, 0.3),
229
- powerup: () => AudioSys.playTone(600, 'square', 0.4, 0.1)
230
- }
231
- };
232
-
233
- // --- CONSTANTS ---
234
- const LOGICAL_WIDTH = 375;
235
- const LOGICAL_HEIGHT = 812;
236
- const GRAVITY = 0.5;
237
- const FRICTION_AIR = 0.93;
238
- const FRICTION_GROUND = 0.80;
239
- const FRICTION_ICE = 0.995;
240
- const DRAG_STRENGTH = 0.18;
241
- const MAX_POWER = 22;
242
- const MIN_POWER = 3;
243
- const CAMERA_SMOOTHING = 0.1;
244
-
245
- const PALETTES = [
246
- { h: 0, top: '#2d2347', bot: '#7a5a8a' },
247
- { h: 1000, top: '#001a33', bot: '#006699' },
248
- { h: 3000, top: '#000000', bot: '#2c003e' }
249
- ];
250
-
251
- const COLORS = { platform: '#1a1a2e', platformHighlight: '#262642', vine: '#25203b', tree: '#2d4a66', treeLight: '#3a5d7c', spike: '#666', saw: '#ff4444', wind: 'rgba(100, 200, 255, 0.3)' };
252
-
253
- // --- GAME STATE ---
254
- const canvas = document.getElementById('gameCanvas');
255
- const ctx = canvas.getContext('2d');
256
- const uiScore = document.getElementById('score-value');
257
- const uiStars = document.getElementById('stars-value');
258
- const uiFinalScore = document.getElementById('final-score');
259
- const uiGameOver = document.getElementById('game-over');
260
- const uiTutorial = document.getElementById('tutorial');
261
- const uiBadgeShield = document.getElementById('badge-shield');
262
- const uiBadgeRocket = document.getElementById('badge-rocket');
263
- const uiBadgeSlow = document.getElementById('badge-slow');
264
-
265
- let state = {
266
- width: LOGICAL_WIDTH, height: LOGICAL_HEIGHT, scale: 1, cameraY: 0,
267
- score: 0, starsCollected: parseInt(localStorage.getItem('ascent_stars')) || 0,
268
- currentSkin: localStorage.getItem('ascent_current_skin') || 'square',
269
- highScore: 0, isPlaying: false, isPaused: false, gameOver: false,
270
- lastPlatformY: 0, platformCount: 0, // CRITICAL: Used for RNG seed
271
- timeScale: 1.0,
272
- hasShield: false, rocketActive: false, rocketTimer: 0, slowMoTimer: 0,
273
- isDragging: false, dragStartX: 0, dragStartY: 0, dragCurrX: 0, dragCurrY: 0,
274
- bgTop: PALETTES[0].top, bgBot: PALETTES[0].bot
275
- };
276
-
277
- const SKINS = [
278
- { id: 'square', color: '#fff' },
279
- { id: 'circle', color: '#00ff88' },
280
- { id: 'triangle', color: '#ff00ff' },
281
- { id: 'gold', color: '#ffd700' }
282
- ];
283
-
284
- // --- ENTITIES ---
285
- class Player {
286
- constructor() { this.w = 24; this.h = 24; this.reset(); }
287
- reset() {
288
- this.x = LOGICAL_WIDTH / 2 - this.w / 2;
289
- this.y = LOGICAL_HEIGHT - 250;
290
- this.vx = 0; this.vy = 0;
291
- this.grounded = false; this.landedPlatform = null;
292
- this.stretchX = 1; this.stretchY = 1; this.rotation = 0;
293
- state.rocketActive = false; state.hasShield = false;
294
- }
295
- update() {
296
- if (state.rocketActive) {
297
- this.vy = -25; this.vx = 0;
298
- state.rocketTimer--;
299
- if (state.rocketTimer <= 0) {
300
- state.rocketActive = false; this.vy = -10;
301
- uiBadgeRocket.style.display = 'none';
302
- }
303
- this.y += this.vy;
304
- this.x += (LOGICAL_WIDTH/2 - this.w/2 - this.x) * 0.1;
305
- return;
306
  }
307
- this.vy += GRAVITY * state.timeScale;
308
- let friction = FRICTION_AIR;
309
- if (this.grounded) {
310
- if (this.landedPlatform && this.landedPlatform.type === 'ice') friction = FRICTION_ICE;
311
- else friction = FRICTION_GROUND;
312
  }
313
- this.vx *= Math.pow(friction, state.timeScale);
314
- this.x += this.vx * state.timeScale;
315
- this.y += this.vy * state.timeScale;
316
- if (this.grounded && this.landedPlatform && this.landedPlatform.type === 'moving') {
317
- this.x += this.landedPlatform.vx * state.timeScale;
318
  }
319
- if (this.x < 0) { this.x = 0; this.vx *= -0.5; }
320
- else if (this.x + this.w > LOGICAL_WIDTH) { this.x = LOGICAL_WIDTH - this.w; this.vx *= -0.5; }
321
- this.stretchX += (1 - this.stretchX) * 0.1;
322
- this.stretchY += (1 - this.stretchY) * 0.1;
323
- this.rotation = (this.vx * 0.1);
324
- this.grounded = false;
325
- }
326
- draw(ctx, isLocal = true, customSkin = null, customX = null, customY = null) {
327
- ctx.save();
328
- const dX = customX !== null ? customX : this.x;
329
- const dY = customY !== null ? customY : this.y;
330
 
331
- ctx.translate(dX + this.w/2, dY + this.h/2);
 
332
 
333
- if (!isLocal) ctx.globalAlpha = 0.6;
334
-
335
- if (state.rocketActive && isLocal) {
336
- ctx.fillStyle = '#ff4444';
337
- ctx.beginPath(); ctx.moveTo(0, -20); ctx.lineTo(10, 10); ctx.lineTo(-10, 10); ctx.fill();
338
- ctx.restore(); return;
339
  }
340
-
341
- if (isLocal) { ctx.rotate(this.rotation); ctx.scale(this.stretchX, this.stretchY); }
342
-
343
- const skinId = customSkin || state.currentSkin;
344
- const skinData = SKINS.find(s => s.id === skinId) || SKINS[0];
345
- ctx.fillStyle = skinData.color;
346
 
347
- if(isLocal) { ctx.shadowBlur = 10; ctx.shadowColor = skinData.color; }
348
-
349
- if (skinId === 'circle') { ctx.beginPath(); ctx.arc(0, 0, this.w/2, 0, Math.PI*2); ctx.fill(); }
350
- else if (skinId === 'triangle') { ctx.beginPath(); ctx.moveTo(0, -this.h/2); ctx.lineTo(this.w/2, this.h/2); ctx.lineTo(-this.w/2, this.h/2); ctx.fill(); }
351
- else { ctx.beginPath(); ctx.roundRect(-this.w/2, -this.h/2, this.w, this.h, 4); ctx.fill(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- ctx.fillStyle = "#000";
354
- if (isLocal) {
355
- if (Math.abs(this.vx) > 1) { const dir = Math.sign(this.vx); ctx.fillRect(dir * 4 - 2, -4, 4, 8); }
356
- else { ctx.fillRect(-5, -2, 3, 3); ctx.fillRect(3, -2, 3, 3); }
357
- } else { ctx.fillRect(-5, -2, 3, 3); ctx.fillRect(3, -2, 3, 3); }
358
 
359
- if (state.hasShield && isLocal) { ctx.strokeStyle = "#00ffff"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, this.w, 0, Math.PI*2); ctx.stroke(); }
 
 
 
360
 
361
- ctx.globalAlpha = 1.0;
362
- ctx.restore();
363
- }
364
- jump(vx, vy) {
365
- this.vx = vx; this.vy = vy; this.grounded = false; this.landedPlatform = null;
366
- this.stretchX = 0.6; this.stretchY = 1.4;
367
- uiTutorial.style.display = 'none';
368
- AudioSys.sfx.jump();
369
- }
370
- }
371
 
372
- class Platform {
373
- constructor(x, y, w, h, type = 'normal') {
374
- this.x = x; this.y = y; this.w = w; this.h = h;
375
- this.type = type;
376
- this.crumbleTimer = 60; this.isCrumbling = false; this.isDestroyed = false; this.hasBounced = false;
 
 
 
 
 
 
 
 
377
 
378
- const r1 = getSeededRandom(1);
379
- this.vx = (type === 'moving') ? (r1 > 0.5 ? 1 : -1) * (1 + r1 * 1.5) : 0;
 
 
 
 
 
 
380
 
381
- const r2 = getSeededRandom(2);
382
- this.hasSpikes = (r2 < 0.2 && type !== 'crumble' && type !== 'bouncy');
383
- this.spikeX = getSeededRandom(3) * (this.w - 20);
384
- this.decorations = [];
385
- this.generateDecorations();
386
- }
387
- triggerCrumble() {
388
- if (this.type === 'crumble' && !this.isCrumbling) { this.isCrumbling = true; AudioSys.sfx.crumble(); }
389
- }
390
- update() {
391
- if (this.type === 'moving') {
392
- this.x += this.vx * state.timeScale;
393
- if (this.x <= 0 || this.x + this.w >= LOGICAL_WIDTH) this.vx *= -1;
394
- }
395
- else if (this.type === 'crumble' && this.isCrumbling) {
396
- this.crumbleTimer -= 1 * state.timeScale;
397
- if (this.crumbleTimer <= 0) { this.isDestroyed = true; createDust(this.x + this.w/2, this.y + this.h/2, 10); }
398
  }
399
- }
400
- generateDecorations() {
401
- if (this.type === 'crumble' || this.type === 'ice' || this.type === 'bouncy') return;
402
- if (getSeededRandom(4) > 0.5) this.decorations.push({ type: 'vine', x: getSeededRandom(5)*(this.w-10), len: 20+getSeededRandom(6)*40 });
403
- if (getSeededRandom(7) > 0.7 && !this.hasSpikes) this.decorations.push({ type: 'tree', x: getSeededRandom(8)*(this.w-20), h: 20+getSeededRandom(9)*30 });
404
- }
405
- draw(ctx) {
406
- if (this.isDestroyed) return;
407
- let dx = this.x, dy = this.y;
408
- if (this.isCrumbling) { dx += (Math.random() - 0.5) * 4; dy += (Math.random() - 0.5) * 4; }
409
- ctx.strokeStyle = COLORS.vine; ctx.lineWidth = 3;
410
- this.decorations.forEach(d => {
411
- if (d.type === 'vine') {
412
- ctx.beginPath(); ctx.moveTo(dx+d.x, dy+this.h);
413
- ctx.quadraticCurveTo(dx+d.x+Math.sin(Date.now()/1000)*5, dy+this.h+d.len/2, dx+d.x, dy+this.h+d.len);
414
- ctx.stroke();
 
 
 
 
 
 
 
 
415
  }
416
- });
417
- ctx.fillStyle = COLORS.platform;
418
- ctx.beginPath(); ctx.roundRect(dx, dy, this.w, this.h, 8); ctx.fill();
419
- if (this.type === 'bouncy') {
420
- ctx.fillStyle = !this.hasBounced ? '#ff66cc' : '#554466'; ctx.fillRect(dx+5, !this.hasBounced?dy:dy+4, this.w-10, !this.hasBounced?6:2);
421
- } else if (this.type === 'ice') { ctx.fillStyle = '#00ffff'; ctx.fillRect(dx, dy, this.w, 8); }
422
- else { ctx.fillStyle = COLORS.platformHighlight; ctx.beginPath(); ctx.roundRect(dx+4, dy+4, this.w-8, this.h-8, 4); ctx.fill(); }
423
- if (this.hasSpikes) { ctx.fillStyle = COLORS.spike; ctx.beginPath(); ctx.moveTo(dx + this.spikeX, dy); ctx.lineTo(dx + this.spikeX + 10, dy - 15); ctx.lineTo(dx + this.spikeX + 20, dy); ctx.fill(); }
424
- }
425
- }
426
-
427
- class Enemy {
428
- constructor(y) { this.y = y; this.x = getSeededRandom(10) * LOGICAL_WIDTH; this.r = 15; this.vx = 2; this.angle = 0; }
429
- update() { this.x += this.vx * state.timeScale; if (this.x < 15 || this.x > LOGICAL_WIDTH-15) this.vx *= -1; this.angle += 0.2 * state.timeScale; }
430
- draw(ctx) {
431
- ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.angle);
432
- ctx.fillStyle = COLORS.saw; ctx.beginPath();
433
- for(let i=0; i<8; i++) { ctx.rotate(Math.PI/4); ctx.lineTo(0, -this.r); ctx.lineTo(5, -this.r+5); ctx.lineTo(-5, -this.r+5); }
434
- ctx.fill(); ctx.fillStyle = "#333"; ctx.beginPath(); ctx.arc(0,0,5,0,Math.PI*2); ctx.fill(); ctx.restore();
435
- }
436
- }
437
-
438
- class Collectible {
439
- constructor(x, y, type) { this.x = x; this.y = y; this.type = type; this.collected = false; this.bob = Math.random() * Math.PI; }
440
- draw(ctx) {
441
- if(this.collected) return;
442
- const by = this.y + Math.sin(Date.now()/300 + this.bob)*3;
443
- ctx.save(); ctx.translate(this.x, by);
444
- if (this.type === 'star') {
445
- ctx.fillStyle = '#ffd700'; ctx.shadowBlur=10; ctx.shadowColor='#ffd700'; ctx.beginPath(); ctx.arc(0,0,8,0,Math.PI*2); ctx.fill();
446
- ctx.fillStyle = '#fff'; ctx.font='10px Arial'; ctx.textAlign='center'; ctx.fillText('★',0,4);
447
- } else {
448
- ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.beginPath(); ctx.arc(0,0,14,0,Math.PI*2); ctx.fill(); ctx.strokeStyle = '#fff'; ctx.stroke();
449
- ctx.font = '16px Arial'; ctx.textAlign='center';
450
- if (this.type === 'rocket') ctx.fillText('🚀',0,5); if (this.type === 'shield') ctx.fillText('🛡️',0,5); if (this.type === 'slow') ctx.fillText('⏳',0,5);
451
  }
452
- ctx.restore();
453
- }
454
- }
455
-
456
- class WindZone {
457
- constructor(y) {
458
- this.y = y; this.h = 100;
459
- this.dir = getSeededRandom(11) > 0.5 ? 1 : -1;
460
- this.particles = [];
461
- for(let i=0; i<10; i++) this.particles.push({x:Math.random()*LOGICAL_WIDTH, y:Math.random()*this.h, s:Math.random()*3+2});
462
- }
463
- update() {
464
- this.particles.forEach(p => { p.x += this.dir * 4 * state.timeScale; if(p.x > LOGICAL_WIDTH) p.x = 0; if(p.x < 0) p.x = LOGICAL_WIDTH; });
465
- if (player.y > this.y && player.y < this.y + this.h) player.vx += this.dir * 0.5 * state.timeScale;
466
- }
467
- draw(ctx) { ctx.fillStyle = COLORS.wind; this.particles.forEach(p => { ctx.fillRect(p.x, this.y + p.y, p.s*2, 2); }); }
468
- }
469
-
470
- const player = new Player();
471
- let platforms = [], enemies = [], winds = [], collectibles = [], particles = [];
472
 
473
- function initLevel() {
474
- platforms = []; enemies = []; winds = []; collectibles = []; particles = [];
475
- state.lastPlatformY = LOGICAL_HEIGHT - 100;
476
- state.platformCount = 0; // RESET COUNT ON INIT
477
-
478
- // Initial Platform
479
- platforms.push(new Platform(0, LOGICAL_HEIGHT - 100, LOGICAL_WIDTH, 150));
480
- generateChunk(LOGICAL_HEIGHT - 250);
481
- }
482
-
483
- function generateChunk(targetY) {
484
- let y = state.lastPlatformY;
485
- let loops = 0;
486
- while (y > targetY && loops < 500) {
487
- loops++;
488
- state.platformCount++; // Increment count for RNG consistency
489
-
490
- const heightMeters = Math.abs(y - (LOGICAL_HEIGHT - 100)) / 10;
491
- const diff = Math.min(1, heightMeters / 1500);
492
-
493
- const rGap = getSeededRandom(12);
494
- const minGap = 80 + (diff * 40);
495
- const maxGap = 160 + (diff * 60);
496
- y -= (minGap + rGap * (maxGap - minGap));
497
-
498
- let w = (60 + getSeededRandom(13) * 100) * (1 - diff * 0.4);
499
- w = Math.max(40, w);
500
- let x = Math.max(10, Math.min(getSeededRandom(14)*(LOGICAL_WIDTH-w), LOGICAL_WIDTH-w-10));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
 
502
- let type = 'normal';
503
- const rand = getSeededRandom(15);
504
- if (heightMeters > 50 && rand < 0.2 + diff*0.3) type = 'moving';
505
- else if (heightMeters > 20 && rand < 0.35) type = 'crumble';
506
- else if (heightMeters > 100 && rand < 0.45) type = 'bouncy';
507
- else if (heightMeters > 150 && rand < 0.55) type = 'ice';
 
 
 
508
 
509
- platforms.push(new Platform(x, y, w, 30 + getSeededRandom(16) * 100, type));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
 
511
- if (getSeededRandom(17) < 0.05) {
512
- const pArr = ['rocket','shield','slow'];
513
- const pType = pArr[Math.floor(getSeededRandom(18)*3)];
514
- collectibles.push(new Collectible(x+w/2, y-30, pType));
515
- } else if (getSeededRandom(19) < 0.25) {
516
- collectibles.push(new Collectible(x+w/2, y-30-Math.random()*30, 'star'));
 
 
517
  }
518
 
519
- if (heightMeters > 200 && getSeededRandom(20) < 0.1 + diff*0.3) enemies.push(new Enemy(y - 50));
520
- if (heightMeters > 300 && getSeededRandom(21) < 0.1) winds.push(new WindZone(y - 100));
521
- }
522
- state.lastPlatformY = y;
523
- }
524
-
525
- function checkCollisions() {
526
- if (state.rocketActive) return;
 
 
527
 
528
- for (let p of platforms) {
529
- if (p.isDestroyed) continue;
530
- if (p.hasSpikes) {
531
- if (player.x + player.w > p.x + p.spikeX && player.x < p.x + p.spikeX + 20 &&
532
- player.y + player.h > p.y - 10 && player.y + player.h < p.y + 10) { killPlayer(); return; }
 
533
  }
534
- if (player.vy >= 0 && player.y + player.h <= p.y + player.vy + 3 &&
535
- player.y + player.h >= p.y - 5 && player.x + player.w > p.x + 5 && player.x < p.x + p.w - 5) {
536
- if (p.type === 'bouncy' && !p.hasBounced) {
537
- player.y = p.y - player.h; player.vy = -15; p.hasBounced = true;
538
- createDust(player.x+player.w/2, player.y+player.h, 5); AudioSys.sfx.jump();
539
- } else {
540
- const impactVelocity = player.vy;
541
- player.y = p.y - player.h; player.vy = 0; player.grounded = true; player.landedPlatform = p;
542
- if (p.type === 'crumble') p.triggerCrumble();
543
- if (impactVelocity > 2.0) { AudioSys.sfx.land(); createDust(player.x+player.w/2, player.y+player.h, 3); }
 
544
  }
 
545
  }
546
- }
547
- for (let e of enemies) { if (Math.hypot((player.x+player.w/2)-e.x, (player.y+player.h/2)-e.y) < player.w/2 + e.r) killPlayer(); }
548
- collectibles.forEach(c => {
549
- if (c.collected) return;
550
- if (Math.hypot((player.x+player.w/2)-c.x, (player.y+player.h/2)-c.y) < 20) {
551
- c.collected = true;
552
- if (c.type === 'star') {
553
- state.score += 10; state.starsCollected++; uiStars.innerText = state.starsCollected;
554
- localStorage.setItem('ascent_stars', state.starsCollected); AudioSys.sfx.coin();
555
- } else activatePowerup(c.type);
556
- }
557
- });
558
- if (player.y > state.cameraY + LOGICAL_HEIGHT + 100) {
559
- if (state.hasShield) { player.vy = -25; state.hasShield = false; uiBadgeShield.style.display = 'none'; AudioSys.sfx.powerup(); }
560
- else killPlayer();
561
- }
562
- }
563
 
564
- function activatePowerup(type) {
565
- AudioSys.sfx.powerup();
566
- if (type === 'shield') { state.hasShield = true; uiBadgeShield.style.display = 'block'; }
567
- else if (type === 'rocket') { state.rocketActive = true; state.rocketTimer = 100; uiBadgeRocket.style.display = 'block'; }
568
- else if (type === 'slow') { state.slowMoTimer = 300; uiBadgeSlow.style.display = 'block'; }
569
- }
570
-
571
- function killPlayer() {
572
- if (state.gameOver) return;
573
- state.gameOver = true; state.isPlaying = false; AudioSys.sfx.die(); if(navigator.vibrate) navigator.vibrate(200);
574
- uiFinalScore.innerText = Math.floor(state.highScore); uiGameOver.style.display = 'flex';
575
- }
576
-
577
- function createDust(x, y, count) {
578
- for(let i=0; i<count; i++) particles.push({ x: x, y: y, vx: (Math.random()-0.5)*4, vy: Math.random()*-2, life: 1.0, size: Math.random()*4+2 });
579
- }
580
-
581
- function updateEnvironment() {
582
- const h = state.highScore;
583
- for(let i=0; i<PALETTES.length-1; i++) {
584
- if (h >= PALETTES[i].h && h <= PALETTES[i+1].h) {
585
- const t = (h - PALETTES[i].h) / (PALETTES[i+1].h - PALETTES[i].h);
586
- state.bgTop = lerpColor(PALETTES[i].top, PALETTES[i+1].top, t);
587
- state.bgBot = lerpColor(PALETTES[i].bot, PALETTES[i+1].bot, t);
588
- break;
589
  }
590
- }
591
- }
592
- function lerpColor(a, b, amount) {
593
- const ah = parseInt(a.replace(/#/g, ''), 16), ar = ah >> 16, ag = ah >> 8 & 0xff, ab = ah & 0xff,
594
- bh = parseInt(b.replace(/#/g, ''), 16), br = bh >> 16, bg = bh >> 8 & 0xff, bb = bh & 0xff,
595
- rr = ar + amount * (br - ar), rg = ag + amount * (bg - ag), rb = ab + amount * (bb - ab);
596
- return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + (rb | 0)).toString(16).slice(1);
597
- }
598
-
599
- function updateCamera() {
600
- let targetY = player.y - LOGICAL_HEIGHT * 0.6;
601
- if (state.rocketActive) targetY = player.y - LOGICAL_HEIGHT * 0.5;
602
- state.cameraY += (targetY - state.cameraY) * CAMERA_SMOOTHING;
603
- const currentHeight = Math.floor((-player.y + (LOGICAL_HEIGHT - 100)) / 10);
604
- if (currentHeight > state.highScore) { state.highScore = currentHeight; uiScore.innerText = state.highScore; }
605
- if (state.cameraY < state.lastPlatformY + LOGICAL_HEIGHT) generateChunk(state.cameraY - LOGICAL_HEIGHT);
606
- if (platforms.length > 20) platforms.shift();
607
- if (collectibles.length > 20) collectibles.shift();
608
- if (enemies.length > 10) enemies.shift();
609
- if (winds.length > 10) winds.shift();
610
- }
611
-
612
- function gameLoop() {
613
- if (state.isPlaying && !state.isPaused) {
614
- if (state.slowMoTimer > 0) { state.timeScale = 0.3; state.slowMoTimer--; if (state.slowMoTimer <= 0) { state.timeScale = 1.0; uiBadgeSlow.style.display = 'none'; } }
615
- else if (!state.rocketActive) state.timeScale = 1.0;
616
-
617
- player.update();
618
- platforms.forEach(p => p.update());
619
- enemies.forEach(e => e.update());
620
- winds.forEach(w => w.update());
621
- checkCollisions();
622
- updateCamera();
623
- updateEnvironment();
624
- for (let i = particles.length - 1; i >= 0; i--) { let p = particles[i]; p.x += p.vx * state.timeScale; p.y += p.vy * state.timeScale; p.life -= 0.05 * state.timeScale; if (p.life <= 0) particles.splice(i, 1); }
625
- }
626
-
627
- // Render
628
- const gradient = ctx.createLinearGradient(0, 0, 0, LOGICAL_HEIGHT);
629
- gradient.addColorStop(0, state.bgTop); gradient.addColorStop(1, state.bgBot);
630
- ctx.fillStyle = gradient; ctx.fillRect(0, 0, LOGICAL_WIDTH, LOGICAL_HEIGHT);
631
 
632
- ctx.save();
633
- ctx.translate(0, -state.cameraY);
 
 
 
 
 
 
 
634
 
635
- winds.forEach(w => { if(isVisible(w.y, 200)) w.draw(ctx); });
636
- platforms.forEach(p => { if(isVisible(p.y, p.h)) p.draw(ctx); });
637
- collectibles.forEach(c => { if(isVisible(c.y, 20)) c.draw(ctx); });
638
- enemies.forEach(e => { if(isVisible(e.y, 50)) e.draw(ctx); });
639
-
640
- // Draw Other Players with Smooth Interpolation
641
- for (let id in otherPlayers) {
642
- const op = otherPlayers[id];
643
- if (!op.isDead && isVisible(op.y, 50)) {
644
- // LERP: Move 20% towards target every frame
645
- op.x += (op.tx - op.x) * 0.2;
646
- op.y += (op.ty - op.y) * 0.2;
647
 
648
- player.draw(ctx, false, op.skin, op.x, op.y);
649
- ctx.fillStyle = "rgba(255,255,255,0.7)";
650
- ctx.font = "10px Arial"; ctx.textAlign = "center";
651
- ctx.fillText(op.name, op.x + 12, op.y - 10);
 
 
 
 
 
652
  }
653
- }
654
-
655
- ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
656
- particles.forEach(p => { ctx.globalAlpha = p.life; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill(); });
657
- ctx.globalAlpha = 1.0;
658
-
659
- if (state.isPlaying && !state.gameOver) player.draw(ctx, true);
660
- drawTrajectory(ctx);
661
-
662
- ctx.restore();
663
- requestAnimationFrame(gameLoop);
664
- }
665
-
666
- function isVisible(y, h) { return y + h > state.cameraY && y < state.cameraY + LOGICAL_HEIGHT; }
667
-
668
- function restartGame() {
669
- state.gameOver = false; state.isPlaying = true; state.cameraY = 0; state.score = 0; state.highScore = 0;
670
- state.rocketActive = false; state.hasShield = false; state.slowMoTimer = 0;
671
- document.querySelectorAll('.powerup-badge').forEach(el => el.style.display = 'none');
672
- uiScore.innerText = "0"; uiGameOver.style.display = 'none';
673
-
674
- // Reset Player and Level (Will generate same level because platformCount resets)
675
- player.reset();
676
- initLevel();
677
- uiTutorial.style.display = 'block';
678
-
679
- // Notify Server
680
- socket.emit('update', [Math.round(player.x), Math.round(player.y), 0, 0, 0, 0]);
681
- }
682
-
683
- function getPointerPos(e) {
684
- const rect = canvas.getBoundingClientRect();
685
- const scaleX = LOGICAL_WIDTH / rect.width; const scaleY = LOGICAL_HEIGHT / rect.height;
686
- let cx = e.touches ? e.touches[0].clientX : e.clientX; let cy = e.touches ? e.touches[0].clientY : e.clientY;
687
- return { x: (cx - rect.left) * scaleX, y: (cy - rect.top) * scaleY };
688
- }
689
-
690
- const startDrag = (e) => {
691
- if (!state.isPlaying || state.isPaused || (!player.grounded && !state.slowMoTimer>0)) return;
692
- if(e.target === canvas) e.preventDefault();
693
- AudioSys.init(); state.isDragging = true;
694
- const pos = getPointerPos(e); state.dragStartX = pos.x; state.dragStartY = pos.y; state.dragCurrX = pos.x; state.dragCurrY = pos.y;
695
- };
696
- const moveDrag = (e) => {
697
- if (!state.isDragging) return;
698
- if(e.target === canvas) e.preventDefault();
699
- const pos = getPointerPos(e); state.dragCurrX = pos.x; state.dragCurrY = pos.y;
700
- };
701
- const endDrag = (e) => {
702
- if (!state.isDragging) return;
703
- state.isDragging = false;
704
- let dx = state.dragStartX - state.dragCurrX; let dy = state.dragStartY - state.dragCurrY;
705
- let power = Math.sqrt(dx*dx + dy*dy) * DRAG_STRENGTH;
706
- if (power > MAX_POWER) { const a = Math.atan2(dy, dx); dx = Math.cos(a)*(MAX_POWER/DRAG_STRENGTH); dy = Math.sin(a)*(MAX_POWER/DRAG_STRENGTH); }
707
- if (Math.sqrt(dx*dx+dy*dy)*DRAG_STRENGTH >= MIN_POWER) player.jump(dx*DRAG_STRENGTH, dy*DRAG_STRENGTH);
708
- };
709
-
710
- window.addEventListener('mousedown', startDrag); window.addEventListener('mousemove', moveDrag); window.addEventListener('mouseup', endDrag);
711
- window.addEventListener('touchstart', startDrag, {passive:false}); window.addEventListener('touchmove', moveDrag, {passive:false}); window.addEventListener('touchend', endDrag);
712
-
713
- document.getElementById('retry-btn').onclick = (e) => { e.stopPropagation(); restartGame(); };
714
-
715
- function drawTrajectory(ctx) {
716
- if (!state.isDragging || !player.grounded) return;
717
- let dx = state.dragStartX - state.dragCurrX; let dy = state.dragStartY - state.dragCurrY;
718
- let dist = Math.sqrt(dx*dx + dy*dy);
719
- if (dist * DRAG_STRENGTH > MAX_POWER) { const a = Math.atan2(dy, dx); dx = Math.cos(a)*(MAX_POWER/DRAG_STRENGTH); dy = Math.sin(a)*(MAX_POWER/DRAG_STRENGTH); }
720
- let vx = dx * DRAG_STRENGTH; let vy = dy * DRAG_STRENGTH;
721
- let sx = player.x + player.w/2, sy = player.y + player.h/2;
722
- ctx.fillStyle = '#fff';
723
- for (let i = 0; i < 15; i++) { sx += vx; sy += vy; vy += GRAVITY; if (i%2===0) { ctx.beginPath(); ctx.arc(sx, sy, 3, 0, Math.PI*2); ctx.fill(); } }
724
- }
725
-
726
- function resize() { canvas.width = LOGICAL_WIDTH; canvas.height = LOGICAL_HEIGHT; }
727
- window.addEventListener('resize', resize);
728
- resize();
729
- uiStars.innerText = state.starsCollected;
730
 
731
- </script>
732
  </body>
733
  </html>
 
2
  <html lang="en">
3
  <head>
4
  <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <title>Bridge Builder MP</title>
7
  <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-user-select: none; touch-action: none; font-family: 'Segoe UI', sans-serif; }
9
+ html, body { width: 100%; height: 100%; overflow: hidden; position: fixed; background: #000; }
 
10
 
11
+ #game-container {
12
+ width: 100%; height: 100%; padding-top: 70px; padding-bottom: 80px;
13
+ background: linear-gradient(to bottom, #ff7e5f, #feb47b, #ffcf91);
14
+ position: relative; overflow: hidden; transform: translate3d(0, 0, 0);
15
+ }
 
 
 
 
 
16
 
17
+ #game-area { width: 100%; height: 100%; position: relative; overflow: hidden; }
 
 
 
18
 
19
+ #world {
20
+ position: absolute; height: 100%; transition: transform 0.5s ease-out;
21
+ transform: translate3d(0, 0, 0); will-change: transform;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ /* Map Elements */
25
+ .platform {
26
+ position: absolute; bottom: 0; background: #1a1a2e;
27
+ border-radius: 4px 4px 0 0; z-index: 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
+
30
+ /* Player & Entities */
31
+ .player-wrapper {
32
+ position: absolute; z-index: 5; transition: left 0.5s linear, bottom 0.5s ease-in, opacity 0.5s;
33
+ will-change: left, bottom;
34
+ }
35
+ .player-body {
36
+ width: 20px; height: 20px; background: #1a1a2e; border-radius: 3px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
+ .player-name {
39
+ position: absolute; top: -25px; left: 50%; transform: translateX(-50%);
40
+ font-size: 12px; color: #1a1a2e; font-weight: bold; white-space: nowrap;
 
 
41
  }
42
+ .stick {
43
+ position: absolute; width: 4px; background: #5c3d2e;
44
+ transform-origin: bottom center; border-radius: 2px;
45
+ bottom: 100px; z-index: 4; will-change: height, transform;
 
46
  }
 
 
 
 
 
 
 
 
 
 
 
47
 
48
+ /* UI */
49
+ #ui-layer { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 20; }
50
 
51
+ #score {
52
+ position: absolute; top: 80px; left: 50%; transform: translateX(-50%);
53
+ font-size: 48px; font-weight: bold; color: rgba(26, 26, 46, 0.3);
 
 
 
54
  }
 
 
 
 
 
 
55
 
56
+ #leaderboard {
57
+ position: absolute; top: 20px; right: 20px;
58
+ background: rgba(255,255,255,0.8); padding: 10px;
59
+ border-radius: 8px; font-size: 12px; min-width: 120px;
60
+ }
61
+ .lb-row { display: flex; justify-content: space-between; margin-bottom: 2px; }
62
+ .lb-name { font-weight: bold; margin-right: 10px; }
63
+
64
+ #login-screen {
65
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
66
+ background: rgba(0,0,0,0.8); z-index: 100;
67
+ display: flex; flex-direction: column; justify-content: center; align-items: center;
68
+ pointer-events: auto;
69
+ }
70
+ input { padding: 15px; font-size: 18px; border-radius: 5px; border: none; margin-bottom: 15px; text-align: center; }
71
+ button { padding: 15px 30px; font-size: 18px; background: #ff7e5f; border: none; border-radius: 5px; color: white; cursor: pointer; }
72
+
73
+ .sun { position: absolute; top: 20px; right: 15%; width: 60px; height: 60px; background: rgba(255,255,255,0.8); border-radius: 50%; box-shadow: 0 0 40px rgba(255,255,255,0.5); }
74
+ .cloud { position: absolute; background: rgba(255,255,255,0.3); border-radius: 50px; }
75
+ #instruction { position: absolute; bottom: 100px; width: 100%; text-align: center; color: white; transition: opacity 0.3s; }
76
+ </style>
77
+ </head>
78
+ <body>
79
 
80
+ <div id="login-screen">
81
+ <h1 style="color:white; margin-bottom:20px;">Bridge Builder MP</h1>
82
+ <input type="text" id="username" placeholder="Enter Username" maxlength="12">
83
+ <button onclick="startGame()">PLAY</button>
84
+ </div>
85
 
86
+ <div id="game-container">
87
+ <div class="sun"></div>
88
+ <div class="cloud" style="top: 40px; left: 10%; width: 80px; height: 25px;"></div>
89
+ <div class="cloud" style="top: 80px; left: 60%; width: 100px; height: 30px;"></div>
90
 
91
+ <div id="game-area">
92
+ <div id="world">
93
+ <!-- Platforms and Players injected here -->
94
+ </div>
95
+ </div>
 
 
 
 
 
96
 
97
+ <div id="ui-layer">
98
+ <div id="score">0</div>
99
+ <div id="instruction">Hold to grow stick</div>
100
+ <div id="leaderboard">Loading...</div>
101
+ </div>
102
+ </div>
103
+
104
+ <script src="/socket.io/socket.io.js"></script>
105
+ <script>
106
+ // --- CONFIG ---
107
+ const PLAYER_SIZE = 20;
108
+ const PLATFORM_HEIGHT = 100;
109
+ const STICK_WIDTH = 4;
110
 
111
+ // --- STATE ---
112
+ let socket;
113
+ let myId = null;
114
+ let gameState = 'login'; // login, playing
115
+ let platforms = [];
116
+ let players = {}; // Local cache of all players
117
+ let worldOffset = 0;
118
+ let audioCtx;
119
 
120
+ const world = document.getElementById('world');
121
+ const scoreDisplay = document.getElementById('score');
122
+ const lbDisplay = document.getElementById('leaderboard');
123
+ const instruction = document.getElementById('instruction');
124
+
125
+ // --- AUDIO ---
126
+ function initAudio() {
127
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
128
+ if (audioCtx.state === 'suspended') audioCtx.resume();
 
 
 
 
 
 
 
 
129
  }
130
+ function playSound(type) {
131
+ if (!audioCtx) return;
132
+ const osc = audioCtx.createOscillator();
133
+ const gain = audioCtx.createGain();
134
+ osc.connect(gain);
135
+ gain.connect(audioCtx.destination);
136
+ const now = audioCtx.currentTime;
137
+
138
+ if (type === 'grow') {
139
+ osc.frequency.setValueAtTime(200, now);
140
+ gain.gain.setValueAtTime(0.05, now);
141
+ osc.start(now); osc.stop(now + 0.05);
142
+ } else if (type === 'hit') {
143
+ osc.type = 'square'; osc.frequency.setValueAtTime(150, now);
144
+ gain.gain.setValueAtTime(0.1, now);
145
+ osc.start(now); osc.stop(now + 0.05);
146
+ } else if (type === 'score') {
147
+ osc.frequency.setValueAtTime(600, now); gain.gain.setValueAtTime(0.1, now);
148
+ osc.start(now); osc.stop(now + 0.1);
149
+ } else if (type === 'die') {
150
+ osc.type = 'sawtooth'; osc.frequency.setValueAtTime(200, now);
151
+ osc.frequency.exponentialRampToValueAtTime(50, now+0.3);
152
+ gain.gain.setValueAtTime(0.2, now);
153
+ osc.start(now); osc.stop(now + 0.3);
154
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
+ // --- GAME START ---
158
+ function startGame() {
159
+ const name = document.getElementById('username').value.trim() || "Guest";
160
+ document.getElementById('login-screen').style.display = 'none';
161
+ initAudio();
162
+
163
+ socket = io();
164
+
165
+ socket.on('connect', () => {
166
+ myId = socket.id;
167
+ socket.emit('join', name);
168
+ });
169
+
170
+ // 1. Initial State
171
+ socket.on('init', (data) => {
172
+ // Clear world
173
+ world.innerHTML = '';
174
+ platforms = data.platforms;
175
+ players = data.players;
176
+
177
+ // Render everything
178
+ platforms.forEach(renderPlatform);
179
+ Object.values(players).forEach(renderPlayer);
180
+
181
+ updateCamera();
182
+ updateLeaderboard();
183
+ });
184
+
185
+ // 2. New Player Joined
186
+ socket.on('player_update', (p) => {
187
+ players[p.id] = p;
188
+ renderPlayer(p);
189
+ updateLeaderboard();
190
+ });
191
+
192
+ // 3. Player Left
193
+ socket.on('player_leave', (id) => {
194
+ if (players[id] && players[id].el) players[id].el.remove();
195
+ if (players[id] && players[id].stickEl) players[id].stickEl.remove();
196
+ delete players[id];
197
+ updateLeaderboard();
198
+ });
199
+
200
+ // 4. New Platform Generated
201
+ socket.on('new_platform', (plat) => {
202
+ platforms.push(plat);
203
+ renderPlatform(plat);
204
+ });
205
+
206
+ // 5. Player Actions (Visuals)
207
+ socket.on('player_action', (data) => {
208
+ const p = players[data.id];
209
+ if (!p) return;
210
+
211
+ if (data.type === 'grow_start') {
212
+ if (data.id === myId) playSound('grow'); // Client prediction handled in input
213
+ p.isGrowing = true;
214
+ visualizeGrowth(p); // Start visual loop for this player
215
+ }
216
+ else if (data.type === 'grow_stop') {
217
+ p.isGrowing = false; // Stop visual loop
218
+ p.stickHeight = data.height;
219
+
220
+ // Update stick visual to match server exact height
221
+ if (p.stickEl) {
222
+ p.stickEl.style.height = data.height + 'px';
223
+ p.stickEl.style.transition = 'transform 0.3s ease-in';
224
+ p.stickEl.style.transform = 'rotate(90deg)';
225
+ }
226
+ if (data.id === myId) playSound('hit');
227
+ }
228
+ });
229
+
230
+ // 6. Result (Move or Die)
231
+ socket.on('player_result', (data) => {
232
+ const p = players[data.id];
233
+ if (!p) return;
234
+
235
+ p.score = data.score;
236
+ if (data.id === myId) scoreDisplay.textContent = p.score;
237
+
238
+ if (data.success) {
239
+ // Animate Walk
240
+ if (p.el) {
241
+ p.el.style.left = data.x + 'px';
242
+ }
243
+ if (data.id === myId) {
244
+ playSound('score');
245
+ setTimeout(updateCamera, 500);
246
+ // Reset stick after walk
247
+ setTimeout(() => resetStick(p), 600);
248
+ } else {
249
+ setTimeout(() => resetStick(p), 600);
250
+ }
251
+ } else {
252
+ // Animate Fall
253
+ const walkDist = p.stickHeight; // Walk to end of stick
254
+ // Simple visual approximation of walking to death
255
+ if (p.el) {
256
+ // 1. Walk out
257
+ p.el.style.transition = 'left 0.3s linear';
258
+ p.el.style.left = (parseFloat(p.el.style.left) + Math.min(walkDist, 100)) + 'px'; // Don't walk too far visual
259
+
260
+ // 2. Fall
261
+ setTimeout(() => {
262
+ if (data.id === myId) playSound('die');
263
+ p.el.style.transition = 'bottom 0.5s ease-in, opacity 0.5s';
264
+ p.el.style.bottom = '-50px';
265
+ p.el.style.opacity = '0';
266
+
267
+ if (p.stickEl) {
268
+ p.stickEl.style.transition = 'transform 0.5s ease-in';
269
+ p.stickEl.style.transform = 'rotate(180deg)';
270
+ }
271
+
272
+ // 3. Respawn
273
+ setTimeout(() => {
274
+ respawnPlayer(p, data.x);
275
+ }, 1000);
276
+ }, 300);
277
+ }
278
+ }
279
+ updateLeaderboard();
280
+ });
281
+
282
+ setupInputs();
283
+ }
284
 
285
+ // --- RENDERERS ---
286
+ function renderPlatform(plat) {
287
+ const div = document.createElement('div');
288
+ div.className = 'platform';
289
+ div.style.left = plat.x + 'px';
290
+ div.style.width = plat.w + 'px';
291
+ div.style.height = PLATFORM_HEIGHT + 'px';
292
+ world.appendChild(div);
293
+ }
294
 
295
+ function renderPlayer(p) {
296
+ // Player Body
297
+ if (!p.el) {
298
+ const wrap = document.createElement('div');
299
+ wrap.className = 'player-wrapper';
300
+
301
+ const name = document.createElement('div');
302
+ name.className = 'player-name';
303
+ name.innerText = p.name;
304
+ // Highlight me
305
+ if (p.id === myId) name.style.color = '#ff4757';
306
+
307
+ const body = document.createElement('div');
308
+ body.className = 'player-body';
309
+ if (p.id !== myId) body.style.opacity = '0.6'; // Ghost effect
310
+
311
+ wrap.appendChild(name);
312
+ wrap.appendChild(body);
313
+ world.appendChild(wrap);
314
+ p.el = wrap;
315
+ }
316
+
317
+ // Player Stick
318
+ if (!p.stickEl) {
319
+ const st = document.createElement('div');
320
+ st.className = 'stick';
321
+ if (p.id !== myId) st.style.opacity = '0.6';
322
+ world.appendChild(st);
323
+ p.stickEl = st;
324
+ }
325
 
326
+ // Position
327
+ p.el.style.left = p.x + 'px';
328
+ p.el.style.bottom = PLATFORM_HEIGHT + 'px';
329
+ p.el.style.opacity = '1';
330
+
331
+ // Stick Position (Always attached to right of current platform usually,
332
+ // but simplified: attached to right of player spawn point)
333
+ resetStick(p);
334
  }
335
 
336
+ function resetStick(p) {
337
+ if (!p.stickEl) return;
338
+ p.stickHeight = 0;
339
+ p.stickEl.style.transition = 'none';
340
+ p.stickEl.style.height = '0px';
341
+ p.stickEl.style.transform = 'rotate(0deg)';
342
+ // Stick starts at player pos + width
343
+ p.stickEl.style.left = (parseFloat(p.el.style.left) + PLAYER_SIZE - STICK_WIDTH/2) + 'px';
344
+ p.stickEl.style.bottom = PLATFORM_HEIGHT + 'px';
345
+ }
346
 
347
+ function respawnPlayer(p, x) {
348
+ p.el.style.transition = 'none';
349
+ p.el.style.opacity = '1';
350
+ p.el.style.bottom = PLATFORM_HEIGHT + 'px';
351
+ p.el.style.left = x + 'px';
352
+ resetStick(p);
353
  }
354
+
355
+ function visualizeGrowth(p) {
356
+ if (!p.isGrowing) return;
357
+
358
+ // If local player, we might have slightly different timing, but visualization keeps it smooth
359
+ // We approximate 3px per frame to match server config
360
+ if (p.stickEl) {
361
+ let h = parseFloat(p.stickEl.style.height || 0);
362
+ h += 3;
363
+ p.stickEl.style.height = h + 'px';
364
+ if (p.id === myId && h % 20 === 0) playSound('grow'); // Audio feedback
365
  }
366
+ requestAnimationFrame(() => visualizeGrowth(p));
367
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
 
369
+ function updateCamera() {
370
+ if (!players[myId] || !players[myId].el) return;
371
+ const myX = parseFloat(players[myId].el.style.left);
372
+ // Center player on screen (screen width / 2)
373
+ // But offset world to the left
374
+ const offset = myX - 50;
375
+ world.style.transform = `translate3d(${-offset}px, 0, 0)`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
+ function updateLeaderboard() {
379
+ const sorted = Object.values(players).sort((a,b) => b.score - a.score).slice(0, 5);
380
+ lbDisplay.innerHTML = sorted.map((p, i) =>
381
+ `<div class="lb-row" style="${p.id===myId?'color:#ff4757':''}">
382
+ <span class="lb-name">#${i+1} ${p.name}</span>
383
+ <span>${p.score}</span>
384
+ </div>`
385
+ ).join('');
386
+ }
387
 
388
+ // --- INPUTS (Hack Proofing: Only send signals) ---
389
+ function setupInputs() {
390
+ const container = document.getElementById('game-container');
391
+
392
+ const start = (e) => {
393
+ if(e.cancelable) e.preventDefault();
394
+ if (instruction.style.opacity !== '0') instruction.style.opacity = '0';
395
+ socket.emit('start_grow');
396
+ };
 
 
 
397
 
398
+ const end = (e) => {
399
+ if(e.cancelable) e.preventDefault();
400
+ socket.emit('stop_grow');
401
+ };
402
+
403
+ container.addEventListener('mousedown', start);
404
+ container.addEventListener('mouseup', end);
405
+ container.addEventListener('touchstart', start, {passive: false});
406
+ container.addEventListener('touchend', end, {passive: false});
407
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
 
409
+ </script>
410
  </body>
411
  </html>