feat: TG Mini App + start menu (new world, join by code, continue game) + short world codes + TG save/load + share invites

This commit is contained in:
Mk 2026-05-26 20:33:39 +00:00
parent 42f3e59a42
commit 528a698cf0
2 changed files with 274 additions and 57 deletions

328
game.js
View File

@ -55,73 +55,260 @@ function customConfirm(msg, onYes) {
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
}
// ==================== TELEGRAM MINI APP ====================
const isTgWebApp = !!(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initData);
let tgUser = null;
let tgSaveLoaded = false;
if (isTgWebApp) {
try {
Telegram.WebApp.ready();
Telegram.WebApp.expand();
tgUser = Telegram.WebApp.initDataUnsafe?.user || null;
console.log('[TG] Mini App detected, user:', tgUser?.id, tgUser?.username);
// Set player name from TG
if (tgUser && tgUser.first_name) {
const tgName = tgUser.username || tgUser.first_name;
localStorage.setItem('minegrechka_playerName', tgName);
}
} catch(e) {
console.warn('[TG] WebApp init error:', e);
}
}
// ==================== WORLD ID И ИГРОКА ====================
let worldId = null;
let playerName = localStorage.getItem('minegrechka_playerName') || null;
// Запрашиваем имя игрока, если его нет
if (!playerName) {
playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
localStorage.setItem('minegrechka_playerName', playerName);
console.log('Player name set:', playerName);
// Priority: URL param > TG saved world > new world
const worldParam = urlParams.get('world');
// ==================== START MENU ====================
let startMenuResolved = false;
function generateShortWorldCode() {
// Generate readable 6-char code: consonants + digits (easy to copy/share)
const c = 'bcdfghjkmnpqrstvwxyz';
const d = '23456789';
let code = '';
for (let i = 0; i < 3; i++) code += c[Math.floor(Math.random() * c.length)];
for (let i = 0; i < 3; i++) code += d[Math.floor(Math.random() * d.length)];
return code;
}
// Берём worldId из URL или генерируем новый
console.log('Current URL:', window.location.href);
const worldParam = urlParams.get('world');
console.log('world param:', worldParam);
function showStartMenu() {
return new Promise((resolve) => {
// If world is in URL — skip menu, auto-join
if (worldParam && worldParam.trim() !== '') {
worldId = worldParam.trim();
console.log('[Menu] Joining world from URL:', worldId);
resolve(worldId);
return;
}
const overlay = document.createElement('div');
overlay.id = 'startMenu';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:99999;font-family:Arial,sans-serif;';
const box = document.createElement('div');
box.style.cssText = 'background:#1a1a2e;border:2px solid #e94560;border-radius:16px;padding:32px;max-width:400px;width:90%;text-align:center;color:#eee;';
// Title
const title = document.createElement('div');
title.style.cssText = 'font-size:28px;font-weight:bold;margin-bottom:8px;color:#e94560;';
title.textContent = '🏗️ GrechkaCraft';
box.appendChild(title);
const subtitle = document.createElement('div');
subtitle.style.cssText = 'font-size:14px;color:#888;margin-bottom:24px;';
subtitle.textContent = isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере';
box.appendChild(subtitle);
// New World button
const btnNew = document.createElement('button');
btnNew.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#27ae60;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;';
btnNew.textContent = '🌍 Новый мир';
btnNew.onmouseover = () => btnNew.style.background = '#2ecc71';
btnNew.onmouseout = () => btnNew.style.background = '#27ae60';
btnNew.onclick = () => {
worldId = generateShortWorldCode();
overlay.remove();
resolve(worldId);
};
box.appendChild(btnNew);
// Join World button
const btnJoin = document.createElement('button');
btnJoin.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#2980b9;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;';
btnJoin.textContent = '🔗 Вступить по коду';
btnJoin.onmouseover = () => btnJoin.style.background = '#3498db';
btnJoin.onmouseout = () => btnJoin.style.background = '#2980b9';
btnJoin.onclick = () => {
const codeInput = document.createElement('input');
codeInput.type = 'text';
codeInput.placeholder = 'Введите код мира (например: bkh237)';
codeInput.style.cssText = 'width:90%;padding:12px;margin:12px 0;border:2px solid #3498db;border-radius:8px;background:#16213e;color:#fff;font-size:16px;text-align:center;text-transform:lowercase;';
codeInput.maxLength = 12;
box.innerHTML = '';
const backTitle = document.createElement('div');
backTitle.style.cssText = 'font-size:20px;font-weight:bold;margin-bottom:16px;color:#3498db;';
backTitle.textContent = '🔗 Вступить в мир';
box.appendChild(backTitle);
box.appendChild(codeInput);
const btnGo = document.createElement('button');
btnGo.style.cssText = 'width:100%;padding:14px;margin-top:12px;background:#2980b9;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;';
btnGo.textContent = 'Войти';
btnGo.onclick = () => {
const code = codeInput.value.trim().toLowerCase();
if (code.length >= 3) {
worldId = code;
overlay.remove();
resolve(worldId);
}
};
box.appendChild(btnGo);
const btnBack = document.createElement('button');
btnBack.style.cssText = 'width:100%;padding:10px;margin-top:8px;background:transparent;color:#888;border:1px solid #444;border-radius:8px;cursor:pointer;font-size:14px;';
btnBack.textContent = '← Назад';
btnBack.onclick = () => { overlay.remove(); showStartMenu().then(resolve); };
box.appendChild(btnBack);
codeInput.focus();
codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
};
box.appendChild(btnJoin);
// History / Continue button (only if TG user with saved game OR localStorage save)
const hasLocalSave = !!localStorage.getItem('minegrechka_save');
if (isTgWebApp || hasLocalSave) {
const btnHistory = document.createElement('button');
btnHistory.style.cssText = 'width:100%;padding:14px;margin-bottom:12px;background:#8e44ad;color:#fff;border:none;border-radius:10px;font-size:18px;cursor:pointer;font-weight:bold;';
btnHistory.textContent = '💾 Продолжить игру';
btnHistory.onmouseover = () => btnHistory.style.background = '#9b59b6';
btnHistory.onmouseout = () => btnHistory.style.background = '#8e44ad';
btnHistory.onclick = async () => {
// TG user: load save from server
if (isTgWebApp && tgUser) {
try {
const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
const data = await resp.json();
if (data.ok && data.world_id) {
worldId = data.world_id;
tgSaveLoaded = true;
overlay.remove();
resolve(worldId);
return;
}
} catch(e) { console.warn('[TG] Load save failed:', e); }
}
// Fallback: load from latest localStorage save
if (hasLocalSave) {
// Generate world from save (worldSeed based)
worldId = generateShortWorldCode();
overlay.remove();
resolve(worldId);
return;
}
customAlert('Сохранений не найдено');
};
box.appendChild(btnHistory);
}
// TG Share button (only in TG context)
if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
const btnInvite = document.createElement('button');
btnInvite.style.cssText = 'width:100%;padding:10px;margin-top:8px;background:transparent;color:#3498db;border:1px solid #3498db;border-radius:8px;cursor:pointer;font-size:14px;';
btnInvite.textContent = '📤 Пригласить друзей';
btnInvite.onclick = () => {
const code = worldId || generateShortWorldCode();
Telegram.WebApp.switchInlineQuery('play ' + code);
};
box.appendChild(btnInvite);
}
// Player name input (if not set)
if (!playerName) {
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.placeholder = 'Ваше имя';
nameInput.style.cssText = 'width:90%;padding:10px;margin:16px 0 8px;border:2px solid #e94560;border-radius:8px;background:#16213e;color:#fff;font-size:16px;text-align:center;';
nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : '';
nameInput.maxLength = 20;
box.appendChild(nameInput);
// Update playerName on input
nameInput.addEventListener('input', () => {
if (nameInput.value.trim()) {
playerName = nameInput.value.trim();
localStorage.setItem('minegrechka_playerName', playerName);
}
});
box.appendChild(document.createElement('br'));
}
overlay.appendChild(box);
document.body.appendChild(overlay);
// If TG user without world in URL, also try to auto-load their save
if (isTgWebApp && tgUser && !worldParam) {
fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
.then(r => r.json())
.then(data => {
if (data.ok && data.world_id) {
// Update the "Continue" button to show the world code
const historyBtn = box.querySelector('button:nth-child(5)'); // rough
}
})
.catch(() => {});
}
});
}
// Проверяем на null, undefined или пустую строку
worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
// ==================== GAME LAUNCH ====================
// Override the old auto-join logic
let gameLaunched = false;
console.log('worldId after params:', worldId, 'type:', typeof worldId);
// Если worldId отсутствует - генерируем новый и записываем в URL
if (!worldId) {
worldId = Math.random().toString(36).substring(2, 10);
console.log('Generated worldId:', worldId);
async function launchGame() {
if (gameLaunched) return;
gameLaunched = true;
if (!playerName) {
playerName = tgUser ? (tgUser.username || tgUser.first_name || 'Игрок') : 'Игрок';
localStorage.setItem('minegrechka_playerName', playerName);
}
// Validate TG initData if in TG context
if (isTgWebApp && Telegram.WebApp.initData) {
try {
const resp = await fetch(SERVER_URL + '/api/tg/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData: Telegram.WebApp.initData })
});
const data = await resp.json();
if (data.ok && data.tg_id) {
console.log('[TG] Authenticated, tg_id:', data.tg_id);
tgUser = tgUser || {};
tgUser.id = data.tg_id;
}
} catch(e) { console.warn('[TG] Auth failed:', e); }
}
// Update URL with world code
try {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('world', worldId);
const newUrlString = newUrl.toString();
console.log('New URL to set:', newUrlString);
// Проверяем, поддерживается ли history API
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
window.history.replaceState(null, '', newUrlString);
console.log('URL after replaceState:', window.location.href);
console.log('URL after replaceState (direct check):', window.location.search);
} else {
console.error('History API not supported!');
if (typeof history !== 'undefined' && history.replaceState) {
history.replaceState(null, '', newUrl.toString());
}
} catch (e) {
console.error('Error updating URL:', e);
}
} catch(e) {}
console.log('Generated new worldId for browser:', worldId);
console.log('[Launch] worldId:', worldId, 'player:', playerName, 'tg:', !!tgUser);
// The rest of the game init will pick up worldId and playerName
// Signal that menu is done
startMenuResolved = true;
window.dispatchEvent(new Event('startMenuDone'));
}
console.log('Final worldId:', worldId, 'Player name:', playerName);
console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`);
// Обработчик клика на worldId для копирования ссылки
document.getElementById('worldId').onclick = () => {
const shareUrl = new URL(window.location.href);
shareUrl.searchParams.set('world', worldId);
const shareUrlString = shareUrl.toString();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(shareUrlString).then(() => {
alert('Ссылка скопирована!');
}).catch(() => {
alert('Ссылка на мир:\n' + shareUrlString);
});
} else {
alert('Ссылка на мир:\n' + shareUrlString);
}
};
// ==================== SOCKET.IO КЛИЕНТ ====================
let socket = null;
@ -190,7 +377,7 @@ function customConfirm(msg, onYes) {
isMultiplayer = true;
// Присоединяемся к миру
socket.emit('join_world', { world_id: worldId, player_name: playerName });
socket.emit('join_world', { world_id: worldId, player_name: playerName, tg_id: (tgUser && tgUser.id) ? tgUser.id : null });
// Показываем в UI
worldIdEl.textContent = worldId;
@ -576,8 +763,13 @@ function customConfirm(msg, onYes) {
});
// ==================== ИНИЦИАЛИЗАЦИЯ СОКЕТА ====================
// Инициализируем socket
initSocket();
// Show start menu first, then init socket
showStartMenu().then((wid) => {
worldId = wid;
// Send tg_id if TG user
initSocket();
launchGame();
});
// ==================== ЗВУКОВОЙ ДВИЖОК ====================
const sounds = {};
@ -2756,6 +2948,16 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
// TG: also save to server
if (tgUser && tgUser.id) {
try {
fetch(SERVER_URL + '/api/tg/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tg_id: tgUser.id, save_data: JSON.stringify(saveData), world_id: worldId, player_name: playerName })
}).catch(e2 => console.warn('[TG] Save to server failed:', e2));
} catch(tgE) {}
}
} catch(e){
console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
@ -2766,7 +2968,21 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
}
function loadGame(){
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
// TG: try server save first
if (tgUser && tgUser.id && tgSaveLoaded) {
try {
const resp = await fetch(SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
const data = await resp.json();
if (data.ok && data.save_data) {
const parsed = JSON.parse(data.save_data);
console.log('[TG] Loaded save from server, HP:', parsed.player?.hp);
tgSaveLoaded = parsed; // store for later use
resolve(parsed);
return;
}
} catch(e) { console.warn('[TG] Load from server failed:', e); }
}
// Пробуем localStorage
try {
const localSave = localStorage.getItem(SAVE_KEY);

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>GrechkaCraft: Multiplayer</title>
<!-- Socket.io Client -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<link rel="stylesheet" href="style.css?v=6">
</head>
@ -94,6 +95,6 @@
</div>
</div>
<script src="game.js?v=44"></script>
<script src="game.js?v=45"></script>
</body>
</html>