feat: friends system, clipboard toast, mob sync fix, player lerp

- 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
This commit is contained in:
Mk 2026-05-26 20:54:29 +00:00
parent 528a698cf0
commit 3733b4b94f
2 changed files with 153 additions and 10 deletions

161
game.js
View File

@ -223,6 +223,89 @@ function customConfirm(msg, onYes) {
Telegram.WebApp.switchInlineQuery('play ' + code); Telegram.WebApp.switchInlineQuery('play ' + code);
}; };
box.appendChild(btnInvite); 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 = '<div style="text-align:left;margin-bottom:16px;">';
for (const f of data.friends) {
if (f.online) {
html += '<div style="padding:8px 0;border-bottom:1px solid #333;">🟢 <b>' + f.name + '</b> — мир ' + f.world_id + '</div>';
} else {
html += '<div style="padding:8px 0;border-bottom:1px solid #333;">⚫ ' + f.name + '</div>';
}
}
html += '</div>';
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 = '<div style="color:#888;font-size:14px;margin-bottom:8px;">Добавить друга по TG ID:</div>';
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) // 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 }); 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 // Показываем в UI
worldIdEl.textContent = worldId; worldIdEl.textContent = worldId;
// XP/Level display // XP/Level display
@ -401,6 +492,10 @@ function customConfirm(msg, onYes) {
isMultiplayer = false; isMultiplayer = false;
otherPlayers.clear(); otherPlayers.clear();
multiplayerStatus.style.display = 'none'; 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 // Обработка world_state
@ -506,6 +601,12 @@ function customConfirm(msg, onYes) {
serverMobs.clear(); serverMobs.clear();
for (const m of data.mobs) { for (const m of data.mobs) {
const sm = createMobFromServer(m); 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); serverMobs.set(m.id, sm);
} }
} }
@ -525,6 +626,8 @@ function customConfirm(msg, onYes) {
otherPlayers.set(data.socket_id, { otherPlayers.set(data.socket_id, {
x: safeSpawnX, x: safeSpawnX,
y: safeSpawnY, y: safeSpawnY,
tx: safeSpawnX,
ty: safeSpawnY,
color: getRandomPlayerColor(data.socket_id), color: getRandomPlayerColor(data.socket_id),
name: data.player_name || 'Игрок' name: data.player_name || 'Игрок'
}); });
@ -538,8 +641,9 @@ function customConfirm(msg, onYes) {
socket.on('player_moved', (data) => { socket.on('player_moved', (data) => {
if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) { if (data.socket_id !== mySocketId && otherPlayers.has(data.socket_id)) {
const p = otherPlayers.get(data.socket_id); const p = otherPlayers.get(data.socket_id);
p.x = data.x; // Lerp: запоминаем целевую позицию, рисуем в рендере с интерполяцией
p.y = data.y; p.tx = data.x;
p.ty = data.y;
// Обновляем имя, если оно пришло // Обновляем имя, если оно пришло
if (data.player_name) { if (data.player_name) {
p.name = data.player_name; p.name = data.player_name;
@ -560,6 +664,14 @@ function customConfirm(msg, onYes) {
socket.on('mob_spawned', (data) => { socket.on('mob_spawned', (data) => {
const sm = createMobFromServer(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); 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 }); hp: u.hp || 1, maxHp: u.hp || 1, dir: u.dir || 1 });
serverMobs.set(u.id, sm); serverMobs.set(u.id, sm);
} }
sm.x += (u.x - sm.x) * 0.3; sm.x += (u.x - sm.x) * 0.35;
sm.y += (u.y - sm.y) * 0.3; sm.y += (u.y - sm.y) * 0.35;
sm.dir = u.dir != null ? u.dir : sm.dir; sm.dir = u.dir != null ? u.dir : sm.dir;
sm.hp = u.hp != null ? u.hp : sm.hp; sm.hp = u.hp != null ? u.hp : sm.hp;
sm.fuse = u.fuse != null ? u.fuse : sm.fuse; sm.fuse = u.fuse != null ? u.fuse : sm.fuse;
@ -1414,6 +1526,31 @@ function customConfirm(msg, onYes) {
const syEl = document.getElementById('sy'); const syEl = document.getElementById('sy');
const todEl = document.getElementById('tod'); const todEl = document.getElementById('tod');
const worldIdEl = document.getElementById('worldId'); 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 playerCountEl = document.getElementById('playerCount');
const hotbarEl = document.getElementById('hotbar'); const hotbarEl = document.getElementById('hotbar');
const craftPanel = document.getElementById('craftPanel'); const craftPanel = document.getElementById('craftPanel');
@ -4836,7 +4973,7 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
genColumn(gx); genColumn(gx);
const sgy = surfaceGyAt(gx); const sgy = surfaceGyAt(gx);
const wx = gx*TILE + 4; const wx = gx*TILE + 4;
const wy = (sgy-2)*TILE; const wy = (sgy-1)*TILE; // 1 блок выше поверхности
// не спавнить в воде // не спавнить в воде
const top = getBlock(gx, sgy); const top = getBlock(gx, sgy);
@ -4874,16 +5011,15 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
mobAI(m, dt); mobAI(m, dt);
if(m.hp<=0) mobs.splice(i,1); 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){ if(isMultiplayer){
for (const [id, sm] of serverMobs) { for (const [id, sm] of serverMobs) {
mobAI(sm, dt);
if(sm.hp <= 0){ if(sm.hp <= 0){
// Schedule removal (don't delete during iteration)
sm.dead = true; sm.dead = true;
} }
} }
// Remove dead server mobs
for (const [id, sm] of serverMobs) { for (const [id, sm] of serverMobs) {
if(sm.dead) serverMobs.delete(id); 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); 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) // other players (multiplayer)
for(const [socketId, p] of otherPlayers){ for(const [socketId, p] of otherPlayers){
if(heroImg.complete){ if(heroImg.complete){

View File

@ -95,6 +95,6 @@
</div> </div>
</div> </div>
<script src="game.js?v=45"></script> <script src="game.js?v=48"></script>
</body> </body>
</html> </html>