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:
parent
528a698cf0
commit
3733b4b94f
161
game.js
161
game.js
|
|
@ -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){
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue