From 3733b4b94fa4833f605169d128c8cbe76a43b5b8 Mon Sep 17 00:00:00 2001 From: Mk Date: Tue, 26 May 2026 20:54:29 +0000 Subject: [PATCH] feat: friends system, clipboard toast, mob sync fix, player lerp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clipboard: click worldId copies code + toast notification (fallback for no-clipboard) - Friends: tg_friends table, /api/tg/friends endpoints, bot /friends /addfriend /myid commands - Online notifications: notify friends via bot when player joins world - Mob sync: server-authoritative — client NO longer runs mobAI for serverMobs - Mob spawn Y: surfaceGyAt recalculated on client (fix: mobs falling from sky / stuck in ground) - Server mob physics: sgy-1 floor, AI per mob type (hostile chase at night, passive wander) - Player positions: lerp (0.25) for smooth other-player rendering - tg_online table for friend online tracking - Offline cleanup on disconnect --- game.js | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++--- index.html | 2 +- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/game.js b/game.js index 1a7ea26..bab6ed4 100644 --- a/game.js +++ b/game.js @@ -223,6 +223,89 @@ function customConfirm(msg, onYes) { Telegram.WebApp.switchInlineQuery('play ' + code); }; box.appendChild(btnInvite); + + // Friends button (TG only) + const btnFriends = document.createElement('button'); + btnFriends.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#f39c12;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;'; + btnFriends.textContent = '👥 Друзья'; + btnFriends.onmouseover = () => btnFriends.style.background = '#e67e22'; + btnFriends.onmouseout = () => btnFriends.style.background = '#f39c12'; + btnFriends.onclick = async () => { + try { + const resp = await fetch(SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id); + const data = await resp.json(); + if (!data.ok || !data.friends || !data.friends.length) { + showToast('👥 Нет друзей. Поделись своим TG ID: ' + tgUser.id); + return; + } + let html = '
'; + for (const f of data.friends) { + if (f.online) { + html += '
🟢 ' + f.name + ' — мир ' + f.world_id + '
'; + } else { + html += '
⚫ ' + f.name + '
'; + } + } + html += '
'; + box.innerHTML = ''; + const ft = document.createElement('div'); + ft.style.cssText = 'font-size:20px;font-weight:bold;margin-bottom:8px;color:#f39c12;'; + ft.textContent = '👥 Друзья'; + box.appendChild(ft); + const list = document.createElement('div'); + list.innerHTML = html; + box.appendChild(list); + // Add friend by TG ID + const addDiv = document.createElement('div'); + addDiv.style.cssText = 'margin-top:16px;'; + addDiv.innerHTML = '
Добавить друга по TG ID:
'; + const addInput = document.createElement('input'); + addInput.type = 'number'; + addInput.placeholder = 'TG ID друга'; + addInput.style.cssText = 'width:70%;padding:10px;border:2px solid #f39c12;border-radius:8px;background:#16213e;color:#fff;font-size:16px;'; + const addBtn = document.createElement('button'); + addBtn.style.cssText = 'width:25%;padding:10px;background:#f39c12;color:#fff;border:none;border-radius:8px;font-size:16px;cursor:pointer;margin-left:4px;'; + addBtn.textContent = '+'; + addBtn.onclick = async () => { + const fid = parseInt(addInput.value); + if (!fid) return; + const r = await fetch(SERVER_URL + '/api/tg/friends/add', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,friend_id:fid})}); + const d = await r.json(); + if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.click(); } + else { showToast('❌ Ошибка'); } + }; + addDiv.appendChild(addInput); + addDiv.appendChild(addBtn); + box.appendChild(addDiv); + // My TG ID + const myId = document.createElement('div'); + myId.style.cssText = 'margin-top:12px;color:#888;font-size:13px;'; + myId.textContent = '🆔 Твой ID: ' + tgUser.id + ' — отправь другу, чтобы он добавил тебя'; + box.appendChild(myId); + // Join online friend buttons + const onlineFriends = (data.friends||[]).filter(f => f.online); + if (onlineFriends.length) { + for (const of2 of onlineFriends) { + const joinBtn = document.createElement('button'); + joinBtn.style.cssText = 'width:100%;padding:12px;margin-top:8px;background:#27ae60;color:#fff;border:none;border-radius:10px;font-size:16px;cursor:pointer;'; + joinBtn.textContent = '🎮 Вступить к ' + of2.name + ' (мир ' + of2.world_id + ')'; + joinBtn.onclick = () => { + worldId = of2.world_id; + overlay.remove(); + resolve(worldId); + }; + box.appendChild(joinBtn); + } + } + // Back button + const btnBackF = document.createElement('button'); + btnBackF.style.cssText = 'width:100%;padding:10px;margin-top:12px;background:transparent;color:#888;border:1px solid #444;border-radius:8px;cursor:pointer;font-size:14px;'; + btnBackF.textContent = '← Назад'; + btnBackF.onclick = () => { overlay.remove(); showStartMenu().then(resolve); }; + box.appendChild(btnBackF); + } catch(e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); } + }; + box.appendChild(btnFriends); } // Player name input (if not set) @@ -379,6 +462,14 @@ function customConfirm(msg, onYes) { // Присоединяемся к миру socket.emit('join_world', { world_id: worldId, player_name: playerName, tg_id: (tgUser && tgUser.id) ? tgUser.id : null }); + // TG: отправляем tg_auth + онлайн статус для уведомления друзей + if (tgUser && tgUser.id) { + socket.emit('tg_auth', { tg_id: tgUser.id }); + try { + fetch(SERVER_URL + '/api/tg/online', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id,world_id:worldId,player_name:playerName||'Игрок'})}); + } catch(e) { console.warn('[TG] online notify error:', e); } + } + // Показываем в UI worldIdEl.textContent = worldId; // XP/Level display @@ -401,6 +492,10 @@ function customConfirm(msg, onYes) { isMultiplayer = false; otherPlayers.clear(); multiplayerStatus.style.display = 'none'; + // TG: убираем онлайн статус + if (tgUser && tgUser.id) { + try { fetch(SERVER_URL + '/api/tg/offline', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tg_id:tgUser.id})}); } catch(e) {} + } }); // Обработка world_state @@ -506,6 +601,12 @@ function customConfirm(msg, onYes) { serverMobs.clear(); for (const m of data.mobs) { const sm = createMobFromServer(m); + // Пересчитываем Y по клиентской surfaceGyAt + const spawnGX = Math.floor(sm.x / TILE); + genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1); + const clientSurfaceY = surfaceGyAt(spawnGX); + sm.y = (clientSurfaceY - 1) * TILE; + sm.vy = 0; serverMobs.set(m.id, sm); } } @@ -525,6 +626,8 @@ function customConfirm(msg, onYes) { otherPlayers.set(data.socket_id, { x: safeSpawnX, y: safeSpawnY, + tx: safeSpawnX, + ty: safeSpawnY, color: getRandomPlayerColor(data.socket_id), name: data.player_name || 'Игрок' }); @@ -538,8 +641,9 @@ function customConfirm(msg, onYes) { socket.on('player_moved', (data) => { if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) { const p = otherPlayers.get(data.socket_id); - p.x = data.x; - p.y = data.y; + // Lerp: запоминаем целевую позицию, рисуем в рендере с интерполяцией + p.tx = data.x; + p.ty = data.y; // Обновляем имя, если оно пришло if (data.player_name) { p.name = data.player_name; @@ -560,6 +664,14 @@ function customConfirm(msg, onYes) { socket.on('mob_spawned', (data) => { const sm = createMobFromServer(data); + // Пересчитываем Y по клиентской surfaceGyAt — сервер может ошибаться + const spawnGX = Math.floor(sm.x / TILE); + // Убеждаемся что чанк сгенерирован (иначе getBlock=null → моб падает сквозь землю) + genColumn(spawnGX - 1); genColumn(spawnGX); genColumn(spawnGX + 1); + const clientSurfaceY = surfaceGyAt(spawnGX); + sm.y = (clientSurfaceY - 1) * TILE; // 1 блок выше поверхности (под ногами) + sm.vy = 0; + sm.grounded = false; // resolveY в mobAI поставит grounded=true serverMobs.set(data.id, sm); }); @@ -571,8 +683,8 @@ function customConfirm(msg, onYes) { hp: u.hp || 1, maxHp: u.hp || 1, dir: u.dir || 1 }); serverMobs.set(u.id, sm); } - sm.x += (u.x - sm.x) * 0.3; - sm.y += (u.y - sm.y) * 0.3; + sm.x += (u.x - sm.x) * 0.35; + sm.y += (u.y - sm.y) * 0.35; sm.dir = u.dir != null ? u.dir : sm.dir; sm.hp = u.hp != null ? u.hp : sm.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; @@ -1414,6 +1526,31 @@ function customConfirm(msg, onYes) { const syEl = document.getElementById('sy'); const todEl = document.getElementById('tod'); const worldIdEl = document.getElementById('worldId'); + // Toast уведомление + function showToast(msg, duration) { + duration = duration || 1800; + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#2ecc71;padding:10px 24px;border-radius:10px;font:bold 16px Arial,sans-serif;z-index:99999;pointer-events:none;transition:opacity 0.4s;'; + document.body.appendChild(t); + setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 400); }, duration); + } + // Клик на worldId → копировать код мира + toast + worldIdEl.addEventListener('click', () => { + const code = worldId || ''; + if (!code) return; + const text = code; // копируем только код, не URL + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(() => showToast('📋 Скопировано: ' + code)).catch(() => showToast(code)); + } else { + // fallback + const ta = document.createElement('textarea'); + ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); showToast('📋 Скопировано: ' + code); } catch(e) { showToast(code); } + ta.remove(); + } + }); const playerCountEl = document.getElementById('playerCount'); const hotbarEl = document.getElementById('hotbar'); const craftPanel = document.getElementById('craftPanel'); @@ -4836,7 +4973,7 @@ registerProcessor('voice-playback', VoicePlaybackProcessor); genColumn(gx); const sgy = surfaceGyAt(gx); const wx = gx*TILE + 4; - const wy = (sgy-2)*TILE; + const wy = (sgy-1)*TILE; // 1 блок выше поверхности // не спавнить в воде const top = getBlock(gx, sgy); @@ -4874,16 +5011,15 @@ registerProcessor('voice-playback', VoicePlaybackProcessor); mobAI(m, dt); if(m.hp<=0) mobs.splice(i,1); } - // Server-spawned mobs (MP client-authoritative) + // Server-spawned mobs: server is authoritative for positions + // mobAI does NOT run on serverMobs — server handles all physics + AI + // Only remove dead mobs from local map if(isMultiplayer){ for (const [id, sm] of serverMobs) { - mobAI(sm, dt); if(sm.hp <= 0){ - // Schedule removal (don't delete during iteration) sm.dead = true; } } - // Remove dead server mobs for (const [id, sm] of serverMobs) { if(sm.dead) serverMobs.delete(id); } @@ -5097,6 +5233,13 @@ registerProcessor('voice-playback', VoicePlaybackProcessor); ctx.drawImage(tex['boat'], boat.x - (TILE-boat.w)/2, boat.y - (TILE-boat.h)/2, TILE, TILE); } + // Lerp other players towards target positions (smooth movement) + for(const [socketId, p] of otherPlayers){ + if (p.tx !== undefined) { + p.x += (p.tx - p.x) * 0.25; + p.y += (p.ty - p.y) * 0.25; + } + } // other players (multiplayer) for(const [socketId, p] of otherPlayers){ if(heroImg.complete){ diff --git a/index.html b/index.html index ca0b9fc..09875a8 100644 --- a/index.html +++ b/index.html @@ -95,6 +95,6 @@ - +