Compare commits
5 Commits
prod-serve
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
ea0694898e | |
|
|
afb5b8a7c6 | |
|
|
2a5f65d393 | |
|
|
8eebf378ab | |
|
|
81f6a0055a |
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Simple ES-module → IIFE bundler for GrechkaCraft
|
||||
* Resolves import/export, wraps in single IIFE, no external deps.
|
||||
* Usage: node bundle.js > ../game-bundled.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.join(__dirname, 'src');
|
||||
const ENTRY = process.argv[2] || 'main.js';
|
||||
|
||||
const visited = new Set();
|
||||
const chunks = [];
|
||||
|
||||
// Named export → variable name mapping per file
|
||||
const fileExports = {}; // file -> [{local, exported}]
|
||||
const fileImports = {}; // file -> [{names, from}]
|
||||
|
||||
function resolveImportPath(fromPath, importerDir) {
|
||||
let resolved = path.resolve(importerDir, fromPath);
|
||||
if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.js')) {
|
||||
resolved += '.js';
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function collectExports(content) {
|
||||
const exports = [];
|
||||
// export { a, b as c }
|
||||
const re1 = /export\s*\{([^}]+)\}/g;
|
||||
let m;
|
||||
while ((m = re1.exec(content)) !== null) {
|
||||
const names = m[1].split(',').map(s => {
|
||||
const parts = s.trim().split(/\s+as\s+/);
|
||||
return { local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() };
|
||||
});
|
||||
exports.push(...names);
|
||||
}
|
||||
// export const/let/var/function/class name
|
||||
const re2 = /export\s+(?:const|let|var|function|class)\s+(\w+)/g;
|
||||
while ((m = re2.exec(content)) !== null) {
|
||||
exports.push({ local: m[1], exported: m[1] });
|
||||
}
|
||||
// export default ...
|
||||
if (/export\s+default\s+/.test(content)) {
|
||||
// Find the expression after export default
|
||||
// Simpler: just use __default as name
|
||||
exports.push({ local: '__default__', exported: 'default' });
|
||||
}
|
||||
return exports;
|
||||
}
|
||||
|
||||
function collectImports(content) {
|
||||
const imports = [];
|
||||
const re = /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const names = m[1].split(',').map(s => {
|
||||
const parts = s.trim().split(/\s+as\s+/);
|
||||
return { local: (parts[1] || parts[0]).trim(), imported: parts[0].trim() };
|
||||
});
|
||||
imports.push({ names, from: m[2] });
|
||||
}
|
||||
// import default
|
||||
const re2 = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
|
||||
while ((m = re2.exec(content)) !== null) {
|
||||
imports.push({ names: [{ local: m[1], imported: 'default' }], from: m[2] });
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
function processFile(filePath) {
|
||||
const relPath = path.relative(SRC, filePath);
|
||||
if (visited.has(relPath)) return;
|
||||
visited.add(relPath);
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
// Collect exports/imports BEFORE stripping
|
||||
fileExports[relPath] = collectExports(content);
|
||||
fileImports[relPath] = collectImports(content);
|
||||
|
||||
// Process dependencies first (depth-first)
|
||||
for (const imp of fileImports[relPath]) {
|
||||
const depPath = resolveImportPath(imp.from, dir);
|
||||
processFile(depPath);
|
||||
}
|
||||
|
||||
// Strip import statements
|
||||
content = content.replace(/import\s*\{[^}]+\}\s*from\s*['"][^'"]+['"];?/g, '');
|
||||
content = content.replace(/import\s+\w+\s+from\s*['"][^'"]+['"];?/g, '');
|
||||
// Strip export keyword but keep declarations
|
||||
content = content.replace(/export\s+default\s+/, 'const __default__ = ');
|
||||
content = content.replace(/export\s+(const|let|var|function|class)\s/g, '$1 ');
|
||||
content = content.replace(/export\s*\{[^}]*\};?/g, '');
|
||||
|
||||
chunks.push({ relPath, content });
|
||||
}
|
||||
|
||||
// Phase 1: Collect all files, build dependency graph
|
||||
processFile(path.join(SRC, ENTRY));
|
||||
|
||||
// Phase 2: Build rename map
|
||||
// For each file's import { X as Y } from './other.js', we need to know:
|
||||
// What is X called in other.js? Then rename Y → X_original
|
||||
// Build: originalName (in source file) → what it's called elsewhere
|
||||
|
||||
// Simpler approach: since we wrap everything in one scope,
|
||||
// we just need to ensure no name collisions.
|
||||
// For now: trust that most names are unique across modules.
|
||||
// If collision, prefix with module path.
|
||||
|
||||
// Phase 3: Emit
|
||||
let output = '// GrechkaCraft — auto-bundled from ES modules\n';
|
||||
output += '(function() {\n';
|
||||
output += '"use strict";\n\n';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
output += `// === ${chunk.relPath} ===\n`;
|
||||
output += chunk.content.trim();
|
||||
output += '\n\n';
|
||||
}
|
||||
|
||||
output += '})();\n';
|
||||
|
||||
process.stdout.write(output);
|
||||
265
game.js
265
game.js
|
|
@ -2395,60 +2395,225 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
|
|||
inventoryPanel.style.display = 'none';
|
||||
};
|
||||
|
||||
// Кнопка сохранения игры (только для одиночного режима)
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.onclick = () => {
|
||||
playSound('click');
|
||||
saveGame();
|
||||
customAlert('Игра сохранена!');
|
||||
};
|
||||
// Меню ⋯ (dropdown)
|
||||
let dropdownOpen = false;
|
||||
const menuBtn = document.getElementById('menuBtn');
|
||||
const menuDropdown = document.getElementById('menuDropdown');
|
||||
const saveItem = document.getElementById('saveItem');
|
||||
const resetItem = document.getElementById('resetItem');
|
||||
const marketItem = document.getElementById('marketItem');
|
||||
const settingsItem = document.getElementById('settingsItem');
|
||||
|
||||
// Кнопка сброса игры (удаление сохранения и создание нового мира)
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
resetBtn.onclick = () => {
|
||||
customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
|
||||
playSound('click');
|
||||
|
||||
// Удаляем сохранение из localStorage
|
||||
try {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
console.log('Сохранение удалено из localStorage');
|
||||
} catch (e) {
|
||||
console.warn('Ошибка удаления сохранения:', e);
|
||||
}
|
||||
|
||||
// Сбрасываем in-memory сохранение
|
||||
inMemorySave = null;
|
||||
|
||||
// Генерируем новый worldId
|
||||
worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Новый worldId после сброса:', worldId);
|
||||
|
||||
// Обновляем URL
|
||||
try {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('world', worldId);
|
||||
const newUrlString = newUrl.toString();
|
||||
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrlString);
|
||||
console.log('URL обновлён:', newUrlString);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка обновления URL:', e);
|
||||
}
|
||||
|
||||
// Перезагружаем страницу
|
||||
location.reload();
|
||||
if (menuBtn) {
|
||||
menuBtn.onclick = null;
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownOpen = !dropdownOpen;
|
||||
menuDropdown.classList.toggle('open', dropdownOpen);
|
||||
});
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', () => {
|
||||
if (dropdownOpen) {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
menuDropdown && menuDropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
// 💾 Сохранить
|
||||
if (saveItem) {
|
||||
saveItem.addEventListener('click', () => {
|
||||
playSound('click');
|
||||
saveGame();
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
customAlert('Игра сохранена!');
|
||||
});
|
||||
}
|
||||
|
||||
// 🔄 Новый мир
|
||||
if (resetItem) {
|
||||
resetItem.addEventListener('click', () => {
|
||||
customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
|
||||
playSound('click');
|
||||
try {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
console.log('Сохранение удалено из localStorage');
|
||||
} catch (e) {
|
||||
console.warn('Ошибка удаления сохранения:', e);
|
||||
}
|
||||
inMemorySave = null;
|
||||
worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Новый worldId после сброса:', worldId);
|
||||
try {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('world', worldId);
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrl.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка обновления URL:', e);
|
||||
}
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 🏪 Рынок
|
||||
if (marketItem) {
|
||||
marketItem.addEventListener('click', () => {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
toggleMarket();
|
||||
});
|
||||
}
|
||||
|
||||
// ⚙ Настройки
|
||||
if (settingsItem) {
|
||||
settingsItem.addEventListener('click', () => {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
toggleSettingsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Показываем кнопку сохранения только если играем одни
|
||||
function updateSaveButtonVisibility() {
|
||||
if (isMultiplayer && otherPlayers.size > 0) {
|
||||
saveBtn.style.display = 'none';
|
||||
if (saveItem) {
|
||||
saveItem.style.display = (isMultiplayer && otherPlayers.size > 0) ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== РЫНОК ====================
|
||||
const marketPanel = document.getElementById('marketPanel');
|
||||
const marketClose = document.getElementById('marketClose');
|
||||
const marketContent = document.getElementById('marketContent');
|
||||
|
||||
if (marketClose) {
|
||||
marketClose.addEventListener('click', () => { marketPanel.style.display = 'none'; });
|
||||
}
|
||||
|
||||
function toggleMarket() {
|
||||
if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
|
||||
openMarket();
|
||||
} else {
|
||||
saveBtn.style.display = 'flex';
|
||||
marketPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function openMarket() {
|
||||
marketPanel.style.display = 'block';
|
||||
renderMarket();
|
||||
}
|
||||
|
||||
let marketOrders = []; // локальный кэш ордеров
|
||||
|
||||
function renderMarket() {
|
||||
if (!socket) {
|
||||
marketContent.innerHTML = '<p style="color:#aaa;text-align:center;">Подключение к серверу...</p>';
|
||||
return;
|
||||
}
|
||||
socket.emit('market_list', {}, (orders) => {
|
||||
if (!orders || !orders.length) {
|
||||
marketOrders = [];
|
||||
marketContent.innerHTML =
|
||||
'<p style="color:#aaa;text-align:center;">Предложений пока нет</p>' +
|
||||
'<div style="margin-top:12px;">' +
|
||||
'<button id="createOrderBtn" style="width:100%;padding:10px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">' +
|
||||
'+ Выставить предмет</button></div>';
|
||||
const btn = document.getElementById('createOrderBtn');
|
||||
if (btn) btn.addEventListener('click', showCreateOrder);
|
||||
return;
|
||||
}
|
||||
marketOrders = orders;
|
||||
let html = '<div style="margin-bottom:8px;"><button id="createOrderBtn" style="padding:8px 14px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">+ Выставить предмет</button></div>';
|
||||
html += orders.map(o =>
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.1);">' +
|
||||
'<div><span style="color:#ffd700;">' + o.offer_item + '</span> ×' + o.offer_qty +
|
||||
' <span style="color:#888;">⇄</span> ' +
|
||||
'<span style="color:#7ecfff;">' + o.want_item + '</span> ×' + o.want_qty +
|
||||
'</div><div style="font-size:11px;color:#888;">' + (o.seller || 'Аноним') + '</div></div>'
|
||||
).join('');
|
||||
marketContent.innerHTML = html;
|
||||
const btn = document.getElementById('createOrderBtn');
|
||||
if (btn) btn.addEventListener('click', showCreateOrder);
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateOrder() {
|
||||
const invKeys = Object.keys(inv).filter(id => inv[id] > 0);
|
||||
marketContent.innerHTML =
|
||||
'<div style="padding:8px;">' +
|
||||
'<h3 style="margin:0 0 10px;font-size:14px;">Выставить предмет</h3>' +
|
||||
'<label style="font-size:12px;color:#aaa;">Отдаю:</label>' +
|
||||
'<select id="offerItem" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">' +
|
||||
invKeys.map(id => '<option value="' + id + '">' + id + ' (×' + inv[id] + ')</option>').join('') +
|
||||
'<option value="">— Пусто —</option></select>' +
|
||||
'<label style="font-size:12px;color:#aaa;">Хочу:</label>' +
|
||||
'<input id="wantItem" placeholder="Название предмета" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;" />' +
|
||||
'<div style="display:flex;gap:8px;">' +
|
||||
'<button id="submitOrder" style="flex:1;padding:8px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;">Выставить</button>' +
|
||||
'<button id="cancelOrder" style="flex:1;padding:8px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Отмена</button>' +
|
||||
'</div></div>';
|
||||
document.getElementById('cancelOrder').addEventListener('click', () => renderMarket());
|
||||
document.getElementById('submitOrder').addEventListener('click', () => {
|
||||
const offerItem = document.getElementById('offerItem').value;
|
||||
const wantItem = document.getElementById('wantItem').value.trim();
|
||||
if (!offerItem || !wantItem) {
|
||||
customAlert('Выберите предмет и укажите что хотите получить');
|
||||
return;
|
||||
}
|
||||
if (socket) {
|
||||
socket.emit('market_create', {
|
||||
offer_item: offerItem,
|
||||
offer_qty: 1,
|
||||
want_item: wantItem,
|
||||
want_qty: 1
|
||||
});
|
||||
playSound('click');
|
||||
setTimeout(renderMarket, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== НАСТРОЙКИ ====================
|
||||
let dayNightCycleEnabled = true;
|
||||
const settingsPanel = document.getElementById('settingsPanel');
|
||||
|
||||
function toggleSettingsPanel() {
|
||||
if (!settingsPanel) return;
|
||||
if (settingsPanel.style.display === 'none' || !settingsPanel.style.display) {
|
||||
settingsPanel.style.display = 'block';
|
||||
renderSettings();
|
||||
} else {
|
||||
settingsPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
if (!settingsPanel) return;
|
||||
const contentEl = document.getElementById('settingsContent');
|
||||
if (!contentEl) return;
|
||||
contentEl.innerHTML =
|
||||
'<div style="margin-bottom:16px;">' +
|
||||
'<label style="display:flex;align-items:center;gap:10px;color:#fff;font-size:14px;cursor:pointer;">' +
|
||||
'<input type="checkbox" id="dayNightToggle" ' + (dayNightCycleEnabled ? 'checked' : '') + ' style="width:20px;height:20px;cursor:pointer;" />' +
|
||||
'Цикл день/ночь</label>' +
|
||||
'<p style="color:#888;font-size:11px;margin:4px 0 0 30px;">Отключите, чтобы заморозить время суток</p>' +
|
||||
'</div>';
|
||||
const toggle = document.getElementById('dayNightToggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('change', (e) => {
|
||||
dayNightCycleEnabled = e.target.checked;
|
||||
console.log('dayNightCycle:', dayNightCycleEnabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsPanel) {
|
||||
const settingsClose = document.getElementById('settingsClose');
|
||||
if (settingsClose) {
|
||||
settingsClose.addEventListener('click', () => { settingsPanel.style.display = 'none'; });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4212,7 +4377,7 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
|
|||
|
||||
// Ускорение времени во время сна
|
||||
if(player.sleeping && isNight()){
|
||||
worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
|
||||
if(dayNightCycleEnabled) worldTime += dt * 8 / DAY_LEN; // В 8 раз быстрее
|
||||
// Восстанавливаем здоровье во время сна
|
||||
player.hp = Math.min(100, player.hp + dt * 20);
|
||||
// Автоматическое пробуждение когда наступает день
|
||||
|
|
@ -4220,7 +4385,7 @@ registerProcessor('voice-playback', VoicePlaybackProcessor);
|
|||
player.sleeping = false;
|
||||
}
|
||||
} else {
|
||||
worldTime += dt / DAY_LEN;
|
||||
if(dayNightCycleEnabled) worldTime += dt / DAY_LEN;
|
||||
}
|
||||
if(worldTime >= 1) worldTime -= 1;
|
||||
|
||||
|
|
|
|||
62
index.html
62
index.html
|
|
@ -25,13 +25,18 @@
|
|||
</div>
|
||||
|
||||
<div id="modeBtn" class="rbtn pe">⛏️</div>
|
||||
<div id="saveBtn" class="rbtn pe">💾</div>
|
||||
<div id="craftBtn" class="rbtn pe">🔨</div>
|
||||
<div id="resetBtn" class="rbtn pe">🔄</div>
|
||||
|
||||
<div id="chatToggle" class="rbtn pe">💬</div>
|
||||
<div id="invToggle" class="rbtn pe">📦</div>
|
||||
<div id="mapToggle" class="rbtn pe">🗺️</div>
|
||||
<div id="menuBtn" class="rbtn pe">⋯</div>
|
||||
<div id="menuDropdown" class="menu-dropdown">
|
||||
<div id="saveItem" class="menu-item">💾 Сохранить</div>
|
||||
<div id="resetItem" class="menu-item">🔄 Новый мир</div>
|
||||
<div id="marketItem" class="menu-item">🏪 Рынок</div>
|
||||
<div id="settingsItem" class="menu-item">⚙️ Настройки</div>
|
||||
</div>
|
||||
<div id="hotbar" class="pe"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -91,8 +96,59 @@
|
|||
<button id="respawnBtn" class="respawn-btn">Возродиться</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="marketPanel" class="panel" style="display:none;">
|
||||
<div class="panel-header">
|
||||
<span>🏪 Рынок</span>
|
||||
<span id="marketClose" class="close" style="cursor:pointer;">✕</span>
|
||||
</div>
|
||||
<div id="marketContent" style="padding:8px;max-height:60vh;overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="settingsPanel" class="panel" style="display:none;">
|
||||
<div class="panel-header">
|
||||
<span>⚙️ Настройки</span>
|
||||
<span id="settingsClose" class="close" style="cursor:pointer;">✕</span>
|
||||
</div>
|
||||
<div id="settingsContent" style="padding:8px;max-height:60vh;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="game.js?v=24"></script>
|
||||
<script src="game.js?v=44"></script>
|
||||
<!-- Выбор игры: Grechka Craft / Grechka Armageddon -->
|
||||
<style>
|
||||
#gamePick{position:fixed;inset:0;z-index:2147483647;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.6);font-family:sans-serif}
|
||||
#gamePick .box{background:#fff;border-radius:12px;padding:24px 32px;text-align:center;max-width:86vw}
|
||||
#gamePick h1{font-size:22px;margin:0 0 16px;color:#111}
|
||||
#gamePick .pick{display:block;width:270px;margin:10px auto;padding:12px 24px;font-size:18px;border:none;border-radius:10px;cursor:pointer;color:#fff}
|
||||
#gamePick .craft{background:#27ae60}#gamePick .arma{background:#2980b9}
|
||||
|
||||
</style>
|
||||
<div id="gamePick"><div class="box">
|
||||
<h1>Во что играем?</h1>
|
||||
<button class="pick craft" id="pickCraft">⛏️ Grechka Craft</button>
|
||||
<button class="pick arma" id="pickArma">💥 Grechka Armageddon</button>
|
||||
</div></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
try {
|
||||
var q = new URLSearchParams(location.search).get('play');
|
||||
var ov = document.getElementById('gamePick');
|
||||
// выбор — всегда вручную, никаких автопереходов
|
||||
if (q === 'craft') { try { localStorage.setItem('grechka-game-choice','craft'); } catch(e){} return; }
|
||||
if (q === 'armageddon') { location.replace('/worms/'); return; }
|
||||
ov.style.display = 'flex';
|
||||
document.getElementById('pickCraft').onclick = function(){
|
||||
try { localStorage.setItem('grechka-game-choice','craft'); } catch(e){}
|
||||
ov.style.display = 'none';
|
||||
};
|
||||
document.getElementById('pickArma').onclick = function(){
|
||||
try { localStorage.setItem('grechka-game-choice','armageddon'); } catch(e){}
|
||||
location.href = '/worms/';
|
||||
};
|
||||
} catch(e){}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
374
src/main.js
374
src/main.js
|
|
@ -1,278 +1,228 @@
|
|||
// ==================== ENTRY POINT ====================
|
||||
import { state } from './core/state.js';
|
||||
import { SERVER_URL } from './config.js';
|
||||
import { showStartMenu, isTgWebApp, tgUser } from './ui/start-menu.js';
|
||||
import { initCanvas } from './core/canvas.js';
|
||||
import { initSocket } from './multiplayer/socket.js';
|
||||
import { sendBlockChange, sendPlayerPosition } from './multiplayer/socket-helpers.js';
|
||||
import { loadSound, playSound } from './audio/sound-engine.js';
|
||||
import { BLOCKS } from './data/blocks.js';
|
||||
import { ITEMS } from './data/items.js';
|
||||
import { TOOLS } from './data/tools.js';
|
||||
import { SMELTING_RECIPES } from './data/recipes.js';
|
||||
import { initTextures } from './render/textures.js';
|
||||
import { initTextures, tex, itemTex } from './render/textures.js';
|
||||
import { getBlock, setBlock, removeBlock } from './world/world-storage.js';
|
||||
import { genColumn, surfaceGyAt, ensureGenAroundCamera, regenerateVisibleChunks } from './world/generation.js';
|
||||
import { genColumn, surfaceGyAt } from './world/generation.js';
|
||||
import { initDB, loadGame, applySave, saveGame } from './game/save.js';
|
||||
import { loop } from './game/loop.js';
|
||||
import { initChat } from './ui/chat.js';
|
||||
import { initFurnace } from './ui/furnace.js';
|
||||
import { initMinimap } from './ui/minimap.js';
|
||||
import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js';
|
||||
import { initMarket } from './ui/market.js';
|
||||
import { initRespawn } from './ui/respawn.js';
|
||||
import { initShare } from './ui/share.js';
|
||||
import { initControls } from './input/controls.js';
|
||||
import { initMouseHandlers } from './input/mouse-handler.js';
|
||||
import { initModes } from './game/modes.js';
|
||||
import { initVoice } from './multiplayer/voice-chat.js';
|
||||
import { resolveY, resolveX } from './physics/collision.js';
|
||||
import { calculateDamage } from './entities/player.js';
|
||||
import { updateWaterFlag } from './physics/water-detect.js';
|
||||
import { updateWaterPhysics } from './physics/water.js';
|
||||
import { explodeAt, activateTNT } from './world/tnt.js';
|
||||
import { useTool } from './data/tools.js';
|
||||
import { rebuildHotbar } from './ui/hotbar.js';
|
||||
import { toggleCraft, closeCraft, toggleInventory, closeInventory } from './ui/craft.js';
|
||||
|
||||
// ==================== 1) CONFIG — parse URL/getName ====================
|
||||
// ==================== 1) CONFIG — parse URL ====================
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
state.SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||||
state.SERVER_URL = SERVER_URL;
|
||||
state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
|
||||
state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
|
||||
|
||||
// Защита от mixed content
|
||||
if (location.protocol === 'https:' && state.SERVER_URL.startsWith('http://')) {
|
||||
console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
|
||||
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
|
||||
// Set TG-related state
|
||||
if (isTgWebApp) {
|
||||
state.myTgId = tgUser?.id || null;
|
||||
}
|
||||
|
||||
// Player name
|
||||
state.playerName = localStorage.getItem('minegrechka_playerName') || null;
|
||||
if (!state.playerName) {
|
||||
state.playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
|
||||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||||
console.log('Player name set:', state.playerName);
|
||||
}
|
||||
state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
|
||||
|
||||
// World ID from URL or generate new
|
||||
console.log('Current URL:', window.location.href);
|
||||
const worldParam = urlParams.get('world');
|
||||
console.log('world param:', worldParam);
|
||||
// ==================== 2) START MENU ====================
|
||||
// Show the start menu before initializing the game.
|
||||
// The menu returns a Promise that resolves with the worldId.
|
||||
async function startGame() {
|
||||
const worldId = await showStartMenu();
|
||||
|
||||
state.worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
|
||||
console.log('worldId after params:', state.worldId, 'type:', typeof state.worldId);
|
||||
// Set worldId and playerName
|
||||
state.worldId = worldId;
|
||||
if (!state.playerName) {
|
||||
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
|
||||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||||
}
|
||||
console.log('[Launch] worldId:', state.worldId, 'player:', state.playerName, 'tg:', isTgWebApp);
|
||||
|
||||
if (!state.worldId) {
|
||||
state.worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Generated worldId:', state.worldId);
|
||||
// Validate TG initData if in TG context
|
||||
if (isTgWebApp && window.Telegram && Telegram.WebApp.initData) {
|
||||
try {
|
||||
const resp = await fetch(state.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);
|
||||
state.myTgId = 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', state.worldId);
|
||||
const newUrlString = newUrl.toString();
|
||||
console.log('New URL to set:', newUrlString);
|
||||
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrlString);
|
||||
console.log('URL after replaceState:', window.location.href);
|
||||
} 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:', state.worldId);
|
||||
}
|
||||
// ==================== 3) CANVAS — init from canvas module ====================
|
||||
initCanvas();
|
||||
|
||||
console.log('Final worldId:', state.worldId, 'Player name:', state.playerName);
|
||||
// ==================== 4) STATE — set DOM refs ====================
|
||||
state.hpEl = document.getElementById('hp');
|
||||
state.foodEl = document.getElementById('food');
|
||||
state.sxEl = document.getElementById('sx');
|
||||
state.syEl = document.getElementById('sy');
|
||||
state.todEl = document.getElementById('tod');
|
||||
state.worldIdEl = document.getElementById('worldId');
|
||||
state.playerCountEl = document.getElementById('playerCount');
|
||||
state.deathEl = document.getElementById('death');
|
||||
state.hotbarEl = document.getElementById('hotbar');
|
||||
state.craftPanel = document.getElementById('craftPanel');
|
||||
state.recipesEl = document.getElementById('recipes');
|
||||
state.inventoryPanel = document.getElementById('inventoryPanel');
|
||||
state.inventoryGrid = document.getElementById('inventoryGrid');
|
||||
state.marketPanel = document.getElementById('marketPanel');
|
||||
state.marketContent = document.getElementById('marketContent');
|
||||
state.tradePanel = document.getElementById('tradePanel');
|
||||
state.tradeContent = document.getElementById('tradeContent');
|
||||
state.multiplayerStatus = document.getElementById('multiplayerStatus');
|
||||
|
||||
// ==================== 2) CANVAS — get elements, resize ====================
|
||||
state.gameEl = document.getElementById('game');
|
||||
state.canvas = document.getElementById('c');
|
||||
state.ctx = state.canvas.getContext('2d');
|
||||
// ==================== 5) TEXTURES — initTextures() ====================
|
||||
initTextures();
|
||||
state.tex = tex;
|
||||
state.itemTex = itemTex;
|
||||
|
||||
// offscreen light map
|
||||
state.lightC = document.createElement('canvas');
|
||||
state.lightCtx = state.lightC.getContext('2d');
|
||||
// ==================== 6) CRAFT/INVENTORY BUTTONS ====================
|
||||
const craftBtn = document.getElementById('craftBtn');
|
||||
const invToggle = document.getElementById('invToggle');
|
||||
const craftClose = document.getElementById('craftClose');
|
||||
const inventoryClose = document.getElementById('inventoryClose');
|
||||
|
||||
state.dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
if (craftBtn) craftBtn.onclick = () => toggleCraft();
|
||||
if (craftClose) craftClose.onclick = () => closeCraft();
|
||||
if (invToggle) invToggle.onclick = () => toggleInventory();
|
||||
if (inventoryClose) inventoryClose.onclick = () => closeInventory();
|
||||
|
||||
function resize() {
|
||||
state.W = state.gameEl.clientWidth;
|
||||
state.H = state.gameEl.clientHeight;
|
||||
state.canvas.width = state.W * state.dpr;
|
||||
state.canvas.height = state.H * state.dpr;
|
||||
state.lightC.width = state.W * state.dpr;
|
||||
state.lightC.height = state.H * state.dpr;
|
||||
state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
// ==================== 7) CONTROLS — initControls ====================
|
||||
initControls();
|
||||
|
||||
// ==================== 3) STATE — init player, inv, etc ====================
|
||||
state.otherPlayers = new Map();
|
||||
state.serverMobs = new Map();
|
||||
state.grid = new Map();
|
||||
state.blocks = [];
|
||||
state.generated = new Set();
|
||||
state.serverOverrides = new Map();
|
||||
state.activeFurnaces = new Map();
|
||||
state.activeTNT = new Set();
|
||||
state.toolDurability = new Map();
|
||||
state.worldSeed = Math.floor(Math.random() * 1000000);
|
||||
// ==================== 8) MOUSE — initMouseHandlers ====================
|
||||
initMouseHandlers();
|
||||
|
||||
// Clouds
|
||||
state.clouds = Array.from({ length: 10 }, () => ({
|
||||
x: Math.random() * 2000,
|
||||
y: -200 - Math.random() * 260,
|
||||
w: 80 + Math.random() * 120,
|
||||
s: 12 + Math.random() * 20
|
||||
}));
|
||||
// ==================== 9) UI MODULES — init all UI ====================
|
||||
initChat();
|
||||
initFurnace();
|
||||
initMinimap();
|
||||
initSaveControls();
|
||||
initMarket();
|
||||
initRespawn();
|
||||
initShare();
|
||||
initModes();
|
||||
|
||||
// Weather
|
||||
state.weatherTimer = 0;
|
||||
state.weatherChangeInterval = 60 + Math.random() * 120;
|
||||
// ==================== 10) SOCKET — initSocket ====================
|
||||
initSocket();
|
||||
|
||||
// Player spawn
|
||||
state.player.x = 6 * state.TILE;
|
||||
state.player.y = 0;
|
||||
state.spawnPoint.x = 6 * state.TILE;
|
||||
state.spawnPoint.y = 0;
|
||||
// ==================== 11) VOICE — initVoice ====================
|
||||
initVoice();
|
||||
|
||||
// Hero image
|
||||
state.heroImg = new Image();
|
||||
state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
|
||||
// ==================== 12) SAVE — loadGame, applySave ====================
|
||||
rebuildHotbar();
|
||||
|
||||
// UI elements
|
||||
state.hpEl = document.getElementById('hp');
|
||||
state.foodEl = document.getElementById('food');
|
||||
state.sxEl = document.getElementById('sx');
|
||||
state.syEl = document.getElementById('sy');
|
||||
state.todEl = document.getElementById('tod');
|
||||
state.worldIdEl = document.getElementById('worldId');
|
||||
state.playerCountEl = document.getElementById('playerCount');
|
||||
state.deathEl = document.getElementById('death');
|
||||
initDB().then(async () => {
|
||||
const loadedSave = await loadGame();
|
||||
if (loadedSave) {
|
||||
await applySave(loadedSave);
|
||||
console.log('Загружено сохранение, HP:', state.player.hp);
|
||||
|
||||
// ==================== 4) TEXTURES — initTextures() ====================
|
||||
initTextures();
|
||||
if (state.player.hp <= 0) {
|
||||
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
|
||||
state.player.hp = 100;
|
||||
state.player.hunger = 100;
|
||||
state.player.o2 = 100;
|
||||
state.player.x = state.spawnPoint.x;
|
||||
state.player.y = state.spawnPoint.y;
|
||||
state.player.vx = state.player.vy = 0;
|
||||
state.player.invuln = 0;
|
||||
state.player.fallStartY = state.player.y;
|
||||
}
|
||||
} else {
|
||||
console.log('Сохранение не найдено, начинаем новую игру');
|
||||
|
||||
// ==================== 5) AUDIO — loadSound for each ====================
|
||||
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
|
||||
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
|
||||
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
|
||||
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
|
||||
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
|
||||
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
|
||||
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
|
||||
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
|
||||
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
|
||||
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
|
||||
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
|
||||
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
|
||||
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
|
||||
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
|
||||
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
|
||||
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
|
||||
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
|
||||
|
||||
// ==================== 6) CONTROLS — initControls ====================
|
||||
initControls();
|
||||
|
||||
// ==================== 7) MOUSE — initMouseHandlers ====================
|
||||
initMouseHandlers();
|
||||
|
||||
// ==================== 8) UI MODULES — init all UI ====================
|
||||
initChat();
|
||||
initFurnace();
|
||||
initMinimap();
|
||||
initSaveControls();
|
||||
initRespawn();
|
||||
initShare();
|
||||
initModes();
|
||||
|
||||
// ==================== 9) SOCKET — initSocket ====================
|
||||
initSocket();
|
||||
|
||||
// ==================== 10) VOICE — initVoice ====================
|
||||
initVoice();
|
||||
|
||||
// ==================== 11) SAVE — loadGame, applySave ====================
|
||||
rebuildHotbar();
|
||||
|
||||
initDB().then(async () => {
|
||||
const loadedSave = await loadGame();
|
||||
if (loadedSave) {
|
||||
await applySave(loadedSave);
|
||||
console.log('Загружено сохранение, HP:', state.player.hp);
|
||||
|
||||
if (state.player.hp <= 0) {
|
||||
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
|
||||
state.player.hp = 100;
|
||||
state.player.hunger = 100;
|
||||
state.player.o2 = 100;
|
||||
state.player.x = state.spawnPoint.x;
|
||||
state.player.y = state.spawnPoint.y;
|
||||
state.player.vx = state.player.vy = 0;
|
||||
state.player.invuln = 0;
|
||||
|
||||
// старт — на поверхности
|
||||
const startGX = 6;
|
||||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||||
const surfaceY = surfaceGyAt(startGX);
|
||||
let safeGY = surfaceY - 1;
|
||||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||||
if (aboveBlock && aboveBlock.t === 'water') {
|
||||
for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
|
||||
const b = getBlock(startGX, gy);
|
||||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||||
safeGY = gy - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.player.y = safeGY * state.TILE;
|
||||
state.player.x = startGX * state.TILE;
|
||||
state.player.fallStartY = state.player.y;
|
||||
}
|
||||
} else {
|
||||
console.log('Сохранение не найдено, начинаем новую игру');
|
||||
|
||||
state.player.hp = 100;
|
||||
state.player.hunger = 100;
|
||||
state.player.o2 = 100;
|
||||
state.player.vx = state.player.vy = 0;
|
||||
state.player.invuln = 0;
|
||||
state.spawnPoint.x = state.player.x;
|
||||
state.spawnPoint.y = state.player.y;
|
||||
|
||||
// старт — на поверхности
|
||||
const startGX = 6;
|
||||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||||
const surfaceY = surfaceGyAt(startGX);
|
||||
let safeGY = surfaceY - 1;
|
||||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||||
if (aboveBlock && aboveBlock.t === 'water') {
|
||||
for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
|
||||
const b = getBlock(startGX, gy);
|
||||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||||
safeGY = gy - 1;
|
||||
break;
|
||||
console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y);
|
||||
|
||||
// Генерируем карту вокруг стартовой позиции
|
||||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||||
genColumn(gx);
|
||||
}
|
||||
}
|
||||
state.player.y = safeGY * state.TILE;
|
||||
state.player.x = startGX * state.TILE;
|
||||
|
||||
// Автосейв при скрытии страницы
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
saveGame();
|
||||
}
|
||||
});
|
||||
|
||||
// Автосейв перед закрытием страницы
|
||||
window.addEventListener('beforeunload', () => {
|
||||
saveGame();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Ошибка инициализации:', err);
|
||||
const startGX = 6;
|
||||
genColumn(startGX);
|
||||
state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
|
||||
state.player.fallStartY = state.player.y;
|
||||
|
||||
state.spawnPoint.x = state.player.x;
|
||||
state.spawnPoint.y = state.player.y;
|
||||
|
||||
console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y);
|
||||
|
||||
// Генерируем карту вокруг стартовой позиции
|
||||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||||
genColumn(gx);
|
||||
}
|
||||
}
|
||||
|
||||
// Автосейв при скрытии страницы
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
saveGame();
|
||||
}
|
||||
});
|
||||
|
||||
// Автосейв перед закрытием страницы
|
||||
window.addEventListener('beforeunload', () => {
|
||||
saveGame();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Ошибка инициализации:', err);
|
||||
const startGX = 6;
|
||||
genColumn(startGX);
|
||||
state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
|
||||
state.player.fallStartY = state.player.y;
|
||||
// ==================== 13) LOOP — startLoop ====================
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||||
genColumn(gx);
|
||||
}
|
||||
// Start the game
|
||||
startGame().catch(err => {
|
||||
console.error('Fatal error starting game:', err);
|
||||
});
|
||||
|
||||
// ==================== 13) LOOP — startLoop ====================
|
||||
requestAnimationFrame(loop);
|
||||
|
|
@ -7,6 +7,7 @@ let voiceStream = null;
|
|||
let audioCtx = null;
|
||||
let voiceProcessor = null;
|
||||
let voiceActive = false;
|
||||
let voiceMode = 'near'; // 'near' — 600px radius, 'world' — global
|
||||
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
|
||||
|
||||
// Кнопка микрофона
|
||||
|
|
@ -16,6 +17,30 @@ voiceBtn.title = 'Голосовой чат (выкл)';
|
|||
voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
|
||||
document.querySelector('.ui').appendChild(voiceBtn);
|
||||
|
||||
// Кнопка режима (рядом / весь мир)
|
||||
const voiceModeBtn = document.createElement('div');
|
||||
voiceModeBtn.innerHTML = '📢';
|
||||
voiceModeBtn.title = 'Режим: рядом (600px)';
|
||||
voiceModeBtn.style.cssText = 'position:absolute;top:74px;right:190px;width:48px;height:52px;border-radius:12px;background:#3498db;z-index:200;display:flex;align-items:center;justify-content:center;font-size:20px;cursor:pointer;border:2px solid rgba(255,255,255,0.7);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;color:#fff;font-weight:bold;';
|
||||
document.querySelector('.ui').appendChild(voiceModeBtn);
|
||||
|
||||
voiceModeBtn.onclick = () => {
|
||||
if (voiceMode === 'near') {
|
||||
voiceMode = 'world';
|
||||
voiceModeBtn.innerHTML = '🌍';
|
||||
voiceModeBtn.title = 'Режим: весь мир';
|
||||
voiceModeBtn.style.background = '#e67e22';
|
||||
} else {
|
||||
voiceMode = 'near';
|
||||
voiceModeBtn.innerHTML = '📢';
|
||||
voiceModeBtn.title = 'Режим: рядом (600px)';
|
||||
voiceModeBtn.style.background = '#3498db';
|
||||
}
|
||||
if (voiceSocket && voiceSocket.connected) {
|
||||
voiceSocket.emit('voice_mode', { mode: voiceMode });
|
||||
}
|
||||
};
|
||||
|
||||
// Индикатор говорящего
|
||||
const speakingIndicator = document.createElement('div');
|
||||
speakingIndicator.style.cssText = 'position:absolute;top:130px;right:10px;z-index:200;display:none;background:rgba(0,0,0,0.7);color:#2ecc71;padding:4px 8px;border-radius:6px;font-size:12px;';
|
||||
|
|
@ -64,7 +89,7 @@ voiceBtn.onclick = async () => {
|
|||
// Подключаемся к голосовому серверу
|
||||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||||
voiceSocket.on('connect', () => {
|
||||
voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
|
||||
voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок', mode: voiceMode });
|
||||
});
|
||||
|
||||
voiceSocket.on('voice_in', (payload) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
// ==================== РЫНОК ПРЕДЛОЖЕНИЙ ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
|
||||
const marketPanel = document.getElementById('marketPanel');
|
||||
const marketClose = document.getElementById('marketClose');
|
||||
const marketContent = document.getElementById('marketContent');
|
||||
|
||||
export function initMarket() {
|
||||
// Кнопка закрытия
|
||||
marketClose.addEventListener('click', () => closeMarket());
|
||||
|
||||
// Слушаем событие от save-controls.js
|
||||
document.addEventListener('toggleMarket', () => toggleMarket());
|
||||
}
|
||||
|
||||
export function toggleMarket() {
|
||||
if (marketPanel.style.display === 'none' || !marketPanel.style.display) {
|
||||
openMarket();
|
||||
} else {
|
||||
closeMarket();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeMarket() {
|
||||
marketPanel.style.display = 'none';
|
||||
}
|
||||
|
||||
function openMarket() {
|
||||
marketPanel.style.display = 'block';
|
||||
renderMarket();
|
||||
}
|
||||
|
||||
function renderMarket() {
|
||||
if (!state.socket) {
|
||||
marketContent.innerHTML = '<p style="color:#aaa;">Подключение к серверу...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Запрашиваем список ордеров с сервера
|
||||
state.socket.emit('market_list', {}, (orders) => {
|
||||
if (!orders || !orders.length) {
|
||||
marketContent.innerHTML = `
|
||||
<p style="color:#aaa;text-align:center;">Предложений пока нет</p>
|
||||
<div style="margin-top:12px;">
|
||||
<button id="createOrderBtn" style="width:100%;padding:10px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">
|
||||
+ Выставить предмет
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
const btn = document.getElementById('createOrderBtn');
|
||||
if (btn) btn.addEventListener('click', showCreateOrder);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="margin-bottom:8px;"><button id="createOrderBtn" style="padding:8px 14px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">+ Выставить предмет</button></div>';
|
||||
|
||||
html += orders.map(o => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.1);">
|
||||
<div>
|
||||
<span style="color:#ffd700;">${o.offer_item}</span> ×${o.offer_qty}
|
||||
<span style="color:#888;"> ⇄ </span>
|
||||
<span style="color:#7ecfff;">${o.want_item}</span> ×${o.want_qty}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#888;">${o.seller || 'Аноним'}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
marketContent.innerHTML = html;
|
||||
const btn = document.getElementById('createOrderBtn');
|
||||
if (btn) btn.addEventListener('click', showCreateOrder);
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateOrder() {
|
||||
const invItems = Object.keys(state.inv).filter(id => state.inv[id] > 0);
|
||||
|
||||
marketContent.innerHTML = `
|
||||
<div style="padding:8px;">
|
||||
<h3 style="margin:0 0 10px;font-size:14px;">Выставить предмет</h3>
|
||||
<label style="font-size:12px;color:#aaa;">Отдаю:</label>
|
||||
<select id="offerItem" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;">
|
||||
${invItems.map(id => `<option value="${id}">${id} (×${state.inv[id]})</option>`).join('')}
|
||||
<option value="">— Пусто —</option>
|
||||
</select>
|
||||
<label style="font-size:12px;color:#aaa;">Хочу:</label>
|
||||
<input id="wantItem" placeholder="Название предмета" style="width:100%;margin-bottom:8px;padding:6px;background:#222;color:#fff;border:1px solid #555;border-radius:4px;" />
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="submitOrder" style="flex:1;padding:8px;background:#4a8c4a;color:#fff;border:none;border-radius:6px;cursor:pointer;">Выставить</button>
|
||||
<button id="cancelOrder" style="flex:1;padding:8px;background:#555;color:#fff;border:none;border-radius:6px;cursor:pointer;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('cancelOrder').addEventListener('click', () => renderMarket());
|
||||
document.getElementById('submitOrder').addEventListener('click', () => {
|
||||
const offerItem = document.getElementById('offerItem').value;
|
||||
const wantItem = document.getElementById('wantItem').value.trim();
|
||||
if (!offerItem || !wantItem) {
|
||||
alert('Выберите предмет и укажите что хотите получить');
|
||||
return;
|
||||
}
|
||||
if (state.socket) {
|
||||
state.socket.emit('market_create', {
|
||||
offer_item: offerItem,
|
||||
offer_qty: 1,
|
||||
want_item: wantItem,
|
||||
want_qty: 1
|
||||
});
|
||||
playSound('click');
|
||||
setTimeout(renderMarket, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,22 +1,55 @@
|
|||
// ==================== КНОПКИ СОХРАНЕНИЯ / СБРОСА ====================
|
||||
// ==================== МЕНЮ ⋯ + СОХРАНЕНИЕ / СБРОС ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { saveGame } from '../game/save.js';
|
||||
|
||||
let dropdownOpen = false;
|
||||
|
||||
export function initSaveControls() {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.onclick = () => {
|
||||
const menuBtn = document.getElementById('menuBtn');
|
||||
const menuDropdown = document.getElementById('menuDropdown');
|
||||
const saveItem = document.getElementById('saveItem');
|
||||
const resetItem = document.getElementById('resetItem');
|
||||
const marketItem = document.getElementById('marketItem');
|
||||
const settingsItem = document.getElementById('settingsItem');
|
||||
|
||||
// Убить возможный старый onclick (предотвращает race condition)
|
||||
menuBtn.onclick = null;
|
||||
|
||||
// Тогл меню
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdownOpen = !dropdownOpen;
|
||||
menuDropdown.classList.toggle('open', dropdownOpen);
|
||||
});
|
||||
|
||||
// Закрытие по клику вне
|
||||
document.addEventListener('click', () => {
|
||||
if (dropdownOpen) {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Предотвращаем закрытие при клике внутри дропдауна
|
||||
menuDropdown.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// 💾 Сохранить
|
||||
saveItem.addEventListener('click', () => {
|
||||
playSound('click');
|
||||
saveGame();
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
alert('Игра сохранена!');
|
||||
};
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
resetBtn.onclick = () => {
|
||||
// 🔄 Новый мир
|
||||
resetItem.addEventListener('click', () => {
|
||||
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
|
||||
playSound('click');
|
||||
|
||||
// Удаляем сохранение из localStorage
|
||||
try {
|
||||
localStorage.removeItem(state.SAVE_KEY);
|
||||
console.log('Сохранение удалено из localStorage');
|
||||
|
|
@ -24,39 +57,53 @@ export function initSaveControls() {
|
|||
console.warn('Ошибка удаления сохранения:', e);
|
||||
}
|
||||
|
||||
// Сбрасываем in-memory сохранение
|
||||
state.inMemorySave = null;
|
||||
|
||||
// Генерируем новый worldId
|
||||
state.worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Новый worldId после сброса:', state.worldId);
|
||||
|
||||
// Обновляем URL
|
||||
try {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('world', state.worldId);
|
||||
const newUrlString = newUrl.toString();
|
||||
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrlString);
|
||||
console.log('URL обновлён:', newUrlString);
|
||||
window.history.replaceState(null, '', newUrl.toString());
|
||||
console.log('URL обновлён:', newUrl.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка обновления URL:', e);
|
||||
}
|
||||
|
||||
// Перезагружаем страницу
|
||||
location.reload();
|
||||
} else {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Показываем кнопку сохранения только если играем одни
|
||||
export function updateSaveButtonVisibility() {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (state.isMultiplayer && state.otherPlayers.size > 0) {
|
||||
saveBtn.style.display = 'none';
|
||||
} else {
|
||||
saveBtn.style.display = 'flex';
|
||||
// 🏪 Рынок
|
||||
marketItem.addEventListener('click', () => {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
// toggleMarket импортируется в main.js, вызовем через кастомное событие
|
||||
document.dispatchEvent(new CustomEvent('toggleMarket'));
|
||||
});
|
||||
|
||||
// ⚙️ Настройки
|
||||
if (settingsItem) {
|
||||
settingsItem.addEventListener('click', () => {
|
||||
dropdownOpen = false;
|
||||
menuDropdown.classList.remove('open');
|
||||
document.dispatchEvent(new CustomEvent('toggleSettings'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем/скрываем пункт сохранения в зависимости от режима
|
||||
export function updateSaveButtonVisibility() {
|
||||
const saveItem = document.getElementById('saveItem');
|
||||
if (!saveItem) return;
|
||||
if (state.isMultiplayer && state.otherPlayers.size > 0) {
|
||||
saveItem.style.display = 'none';
|
||||
} else {
|
||||
saveItem.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
// ==================== START MENU ====================
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
// Detect Telegram WebApp
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Export TG info for other modules
|
||||
export { isTgWebApp, tgUser };
|
||||
|
||||
// ==================== HELPERS ====================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
function customAlert(msg) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-modal-overlay';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100000;';
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = 'background:#C6C6C6;border:4px solid #000;padding:24px;max-width:360px;width:90%;box-shadow:4px 4px 0 #000;font-family:Courier New,monospace;';
|
||||
const text = document.createElement('div');
|
||||
text.textContent = msg;
|
||||
text.style.marginBottom = '16px';
|
||||
text.style.fontWeight = 'bold';
|
||||
text.style.fontSize = '16px';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'OK';
|
||||
btn.style.cssText = 'padding:8px 24px;font-family:Courier New,monospace;font-weight:bold;cursor:pointer;border:3px solid #000;background:#5A8F3C;color:#fff;box-shadow:2px 2px 0 #000;';
|
||||
btn.onclick = () => overlay.remove();
|
||||
box.appendChild(text);
|
||||
box.appendChild(btn);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ==================== MAIN START MENU ====================
|
||||
|
||||
// Minecraft-style CSS for the start menu
|
||||
const mcCSS = `
|
||||
.mc-overlay { position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;
|
||||
display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;
|
||||
image-rendering:pixelated; }
|
||||
.mc-bg { position:absolute;inset:0;background:#000;opacity:0.75; }
|
||||
.mc-dirt { position:absolute;bottom:0;left:0;right:0;height:40%;
|
||||
background:repeating-linear-gradient(0deg,#8B6914 0px,#8B6914 2px,#7A5B12 2px,#7A5B12 4px);opacity:0.3; }
|
||||
.mc-box { position:relative;z-index:1;background:#C6C6C6;border:4px solid #000;
|
||||
padding:0;max-width:420px;width:92%;box-shadow:4px 4px 0 #000; }
|
||||
.mc-titlebar { background:#1a1a2e;padding:12px 16px;text-align:center;
|
||||
border-bottom:4px solid #000; }
|
||||
.mc-title { font-size:24px;font-weight:bold;color:#FFAA00;text-shadow:2px 2px 0 #000;
|
||||
letter-spacing:2px; }
|
||||
.mc-subtitle { font-size:13px;color:#AAA;margin-top:4px; }
|
||||
.mc-body { padding:16px;background:#C6C6C6; }
|
||||
.mc-btn { display:block;width:100%;padding:10px 16px;margin-bottom:8px;
|
||||
font-family:'Courier New',monospace;font-size:16px;font-weight:bold;
|
||||
cursor:pointer;text-align:center;border:3px solid #000;
|
||||
image-rendering:pixelated;box-shadow:3px 3px 0 #000;
|
||||
transition:transform 0.05s,box-shadow 0.05s; }
|
||||
.mc-btn:active { transform:translate(2px,2px);box-shadow:1px 1px 0 #000; }
|
||||
.mc-btn-green { background:#5A8F3C;color:#E0E0E0;border-color:#3A5F1C;text-shadow:1px 1px 0 #222; }
|
||||
.mc-btn-green:hover { background:#6DAF4E; }
|
||||
.mc-btn-blue { background:#3C5F9E;color:#E0E0E0;border-color:#2A4070;text-shadow:1px 1px 0 #222; }
|
||||
.mc-btn-blue:hover { background:#4B73BE; }
|
||||
.mc-btn-purple { background:#7B3FA0;color:#E0E0E0;border-color:#5A2C78;text-shadow:1px 1px 0 #222; }
|
||||
.mc-btn-purple:hover { background:#9555BE; }
|
||||
.mc-btn-orange { background:#B86E00;color:#E0E0E0;border-color:#8A5200;text-shadow:1px 1px 0 #222; }
|
||||
.mc-btn-orange:hover { background:#DA8500; }
|
||||
.mc-btn-red { background:#9E2B2B;color:#E0E0E0;border-color:#6E1B1B;text-shadow:1px 1px 0 #222; }
|
||||
.mc-btn-red:hover { background:#BE3B3B; }
|
||||
.mc-btn-gray { background:#555;color:#AAA;border-color:#333;text-shadow:1px 1px 0 #111; }
|
||||
.mc-btn-gray:hover { background:#666;color:#DDD; }
|
||||
.mc-btn-outline { background:transparent;color:#555;border-color:#555;
|
||||
text-shadow:none;box-shadow:2px 2px 0 #333; }
|
||||
.mc-btn-outline:hover { background:#DDD;color:#222; }
|
||||
.mc-input { width:100%;padding:8px 12px;font-family:'Courier New',monospace;
|
||||
font-size:16px;background:#FFF;border:3px solid #000;color:#222;
|
||||
box-shadow:inset 2px 2px 0 #999;margin:4px 0;box-sizing:border-box; }
|
||||
.mc-input:focus { outline:none;border-color:#FFAA00; }
|
||||
.mc-label { font-size:13px;color:#444;font-weight:bold;margin:8px 0 4px; }
|
||||
.mc-divider { border:none;border-top:2px solid #999;margin:12px 0; }
|
||||
.mc-friend-row { display:flex;justify-content:space-between;align-items:center;
|
||||
padding:6px 8px;margin-bottom:4px;background:#E8E8E8;border:2px solid #999;
|
||||
box-shadow:2px 2px 0 #999; }
|
||||
.mc-friend-btn { padding:4px 10px;font-family:'Courier New',monospace;
|
||||
font-size:13px;font-weight:bold;cursor:pointer;border:2px solid #000;
|
||||
box-shadow:1px 1px 0 #000; }
|
||||
.mc-friend-btn:active { transform:translate(1px,1px);box-shadow:none; }
|
||||
.mc-friend-btn-green { background:#5A8F3C;color:#E0E0E0; }
|
||||
.mc-friend-btn-red { background:#9E2B2B;color:#E0E0E0; }
|
||||
.mc-checkbox-row { display:flex;align-items:center;gap:8px;font-size:13px;color:#444;cursor:pointer; }
|
||||
.mc-checkbox { width:18px;height:18px;accent-color:#5A8F3C; }
|
||||
`;
|
||||
|
||||
export function showStartMenu() {
|
||||
return new Promise(async (resolve) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const worldParam = urlParams.get('world');
|
||||
const tgStartParam = (isTgWebApp && Telegram.WebApp.initDataUnsafe?.start_param)
|
||||
? Telegram.WebApp.initDataUnsafe.start_param.trim()
|
||||
: null;
|
||||
|
||||
// If world is in URL or TG start param — skip menu, auto-join
|
||||
if ((worldParam && worldParam.trim() !== '') || tgStartParam) {
|
||||
const worldId = (worldParam && worldParam.trim()) || tgStartParam;
|
||||
console.log('[Menu] Joining world from URL/startParam:', worldId);
|
||||
|
||||
// Set player name from TG or localStorage or default
|
||||
if (!state.playerName) {
|
||||
if (isTgWebApp && tgUser) {
|
||||
state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
|
||||
} else {
|
||||
state.playerName = localStorage.getItem('minegrechka_playerName') || 'Игрок';
|
||||
}
|
||||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||||
}
|
||||
|
||||
resolve(worldId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject CSS
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = mcCSS;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
// Build overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'mc-overlay';
|
||||
overlay.id = 'startMenu';
|
||||
overlay.innerHTML = '<div class="mc-bg"></div><div class="mc-dirt"></div>';
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'mc-box';
|
||||
|
||||
// Title bar
|
||||
const titleBar = document.createElement('div');
|
||||
titleBar.className = 'mc-titlebar';
|
||||
titleBar.innerHTML = '<div class="mc-title">⛏️ GrechkaCraft</div>' +
|
||||
'<div class="mc-subtitle">' + (isTgWebApp ? ('Привет, ' + (tgUser?.first_name || 'Игрок') + '!') : 'Voxel-мир в браузере') + '</div>';
|
||||
box.appendChild(titleBar);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'mc-body';
|
||||
|
||||
// Mutable worldId holder
|
||||
let selectedWorldId = null;
|
||||
|
||||
// === Buttons ===
|
||||
|
||||
// 🌍 New World
|
||||
const btnNew = document.createElement('button');
|
||||
btnNew.className = 'mc-btn mc-btn-green';
|
||||
btnNew.textContent = '🌍 Новый мир';
|
||||
btnNew.onclick = () => {
|
||||
selectedWorldId = generateShortWorldCode();
|
||||
overlay.remove();
|
||||
styleEl.remove();
|
||||
resolve(selectedWorldId);
|
||||
};
|
||||
body.appendChild(btnNew);
|
||||
|
||||
// 🔗 Join by code
|
||||
const btnJoin = document.createElement('button');
|
||||
btnJoin.className = 'mc-btn mc-btn-blue';
|
||||
btnJoin.textContent = '🔗 Вступить по коду';
|
||||
btnJoin.onclick = () => {
|
||||
body.innerHTML = '';
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'mc-label';
|
||||
lbl.textContent = 'Код мира:';
|
||||
body.appendChild(lbl);
|
||||
const codeInput = document.createElement('input');
|
||||
codeInput.className = 'mc-input';
|
||||
codeInput.type = 'text';
|
||||
codeInput.placeholder = 'bkh237';
|
||||
codeInput.maxLength = 12;
|
||||
body.appendChild(codeInput);
|
||||
const btnGo = document.createElement('button');
|
||||
btnGo.className = 'mc-btn mc-btn-blue';
|
||||
btnGo.textContent = 'Войти →';
|
||||
btnGo.onclick = () => {
|
||||
const code = codeInput.value.trim().toLowerCase();
|
||||
if (code.length >= 3) {
|
||||
selectedWorldId = code;
|
||||
overlay.remove();
|
||||
styleEl.remove();
|
||||
resolve(selectedWorldId);
|
||||
}
|
||||
};
|
||||
body.appendChild(btnGo);
|
||||
const btnBack = document.createElement('button');
|
||||
btnBack.className = 'mc-btn mc-btn-gray';
|
||||
btnBack.textContent = '← Назад';
|
||||
btnBack.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
|
||||
body.appendChild(btnBack);
|
||||
codeInput.focus();
|
||||
codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') btnGo.click(); });
|
||||
};
|
||||
body.appendChild(btnJoin);
|
||||
|
||||
// 💾 Continue saved game
|
||||
const hasLocalSave = !!localStorage.getItem(state.SAVE_KEY || 'minegrechka_save');
|
||||
if (isTgWebApp || hasLocalSave) {
|
||||
const btnHistory = document.createElement('button');
|
||||
btnHistory.className = 'mc-btn mc-btn-purple';
|
||||
btnHistory.textContent = '💾 Продолжить игру';
|
||||
btnHistory.onclick = async () => {
|
||||
if (isTgWebApp && tgUser) {
|
||||
try {
|
||||
const resp = await fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id);
|
||||
const data = await resp.json();
|
||||
if (data.ok && data.world_id) {
|
||||
selectedWorldId = data.world_id;
|
||||
tgSaveLoaded = true;
|
||||
overlay.remove();
|
||||
styleEl.remove();
|
||||
resolve(selectedWorldId);
|
||||
return;
|
||||
}
|
||||
} catch (e) { console.warn('[TG] Load save failed:', e); }
|
||||
}
|
||||
if (hasLocalSave) {
|
||||
// Continue with a new world code (the save will be loaded separately by initDB/loadGame)
|
||||
selectedWorldId = generateShortWorldCode();
|
||||
overlay.remove();
|
||||
styleEl.remove();
|
||||
resolve(selectedWorldId);
|
||||
return;
|
||||
}
|
||||
customAlert('Сохранений не найдено');
|
||||
};
|
||||
body.appendChild(btnHistory);
|
||||
}
|
||||
|
||||
// === TG-only buttons ===
|
||||
if (isTgWebApp && window.Telegram && Telegram.WebApp.switchInlineQuery) {
|
||||
// 📤 Invite friends
|
||||
const btnInvite = document.createElement('button');
|
||||
btnInvite.className = 'mc-btn mc-btn-outline';
|
||||
btnInvite.style.marginTop = '16px';
|
||||
btnInvite.textContent = '📤 Пригласить друзей';
|
||||
btnInvite.onclick = () => {
|
||||
const code = selectedWorldId || generateShortWorldCode();
|
||||
const url = 'https://t.me/' + state.TELEGRAM_BOT_USERNAME + '/app?startapp=' + encodeURIComponent(code);
|
||||
if (Telegram.WebApp && Telegram.WebApp.openTelegramLink) {
|
||||
Telegram.WebApp.openTelegramLink('https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent('🏗️ Заходи в GrechkaCraft! Мир: ' + code));
|
||||
} else {
|
||||
navigator.clipboard.writeText(url).then(() => showToast('📋 Скопировано!')).catch(() => showToast(url));
|
||||
}
|
||||
};
|
||||
body.appendChild(btnInvite);
|
||||
|
||||
// 👥 Friends
|
||||
const btnFriends = document.createElement('button');
|
||||
btnFriends.className = 'mc-btn mc-btn-orange';
|
||||
btnFriends.style.marginTop = '8px';
|
||||
btnFriends.textContent = '👥 Друзья';
|
||||
btnFriends.onclick = async () => {
|
||||
try {
|
||||
const resp = await fetch(state.SERVER_URL + '/api/tg/friends?tg_id=' + tgUser.id);
|
||||
const data = await resp.json();
|
||||
body.innerHTML = '';
|
||||
|
||||
// Back button at top
|
||||
const btnBackTop = document.createElement('button');
|
||||
btnBackTop.className = 'mc-btn mc-btn-gray';
|
||||
btnBackTop.textContent = '← Назад';
|
||||
btnBackTop.style.marginBottom = '12px';
|
||||
btnBackTop.onclick = () => { overlay.remove(); styleEl.remove(); showStartMenu().then(resolve); };
|
||||
body.appendChild(btnBackTop);
|
||||
|
||||
// Nickname section
|
||||
if (data.nickname) {
|
||||
const nickDiv = document.createElement('div');
|
||||
nickDiv.className = 'mc-friend-row';
|
||||
nickDiv.innerHTML = '🔗 Твой ник: <b>' + data.nickname + '</b>';
|
||||
nickDiv.style.background = '#D5E8C5';
|
||||
body.appendChild(nickDiv);
|
||||
} else {
|
||||
const nickLabel = document.createElement('div');
|
||||
nickLabel.className = 'mc-label';
|
||||
nickLabel.style.color = '#9E2B2B';
|
||||
nickLabel.textContent = '⚡ Задай ник, чтобы друзья могли тебя найти:';
|
||||
body.appendChild(nickLabel);
|
||||
const nickRow = document.createElement('div');
|
||||
nickRow.style.display = 'flex';
|
||||
nickRow.style.gap = '4px';
|
||||
nickRow.style.marginBottom = '8px';
|
||||
const nickInput = document.createElement('input');
|
||||
nickInput.className = 'mc-input';
|
||||
nickInput.style.flex = '1';
|
||||
nickInput.type = 'text';
|
||||
nickInput.maxLength = 16;
|
||||
nickInput.placeholder = 'Твой ник';
|
||||
const nickBtn = document.createElement('button');
|
||||
nickBtn.className = 'mc-btn mc-btn-green';
|
||||
nickBtn.style.width = 'auto';
|
||||
nickBtn.style.marginBottom = '0';
|
||||
nickBtn.style.padding = '8px 16px';
|
||||
nickBtn.textContent = '✓';
|
||||
nickBtn.onclick = async () => {
|
||||
const n = nickInput.value.trim().toLowerCase();
|
||||
if (!n || n.length < 2) { showToast('❌ Минимум 2 символа'); return; }
|
||||
const check = await fetch(state.SERVER_URL + '/api/nick/check?nick=' + encodeURIComponent(n));
|
||||
const cd = await check.json();
|
||||
if (!cd.available) { showToast('❌ Ник занят'); return; }
|
||||
const setResp = await fetch(state.SERVER_URL + '/api/nick/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tg_id: tgUser.id, nickname: n })
|
||||
});
|
||||
const sd = await setResp.json();
|
||||
if (sd.ok) { showToast('✅ Ник: ' + sd.nickname); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
|
||||
};
|
||||
nickRow.appendChild(nickInput);
|
||||
nickRow.appendChild(nickBtn);
|
||||
body.appendChild(nickRow);
|
||||
}
|
||||
|
||||
// Friends list
|
||||
if (data.friends && data.friends.length) {
|
||||
const listLabel = document.createElement('div');
|
||||
listLabel.className = 'mc-label';
|
||||
listLabel.textContent = '👥 Друзья:';
|
||||
body.appendChild(listLabel);
|
||||
for (const f of data.friends) {
|
||||
const fDiv = document.createElement('div');
|
||||
fDiv.className = 'mc-friend-row';
|
||||
const statusDot = f.online ? '🟢' : '⚫';
|
||||
let info = statusDot + ' <b>' + f.nickname + '</b>';
|
||||
if (f.online && f.world_id) info += ' — мир ' + f.world_id;
|
||||
const fInfo = document.createElement('div');
|
||||
fInfo.innerHTML = info;
|
||||
fDiv.appendChild(fInfo);
|
||||
if (f.online && f.world_id) {
|
||||
const joinBtn = document.createElement('button');
|
||||
joinBtn.className = 'mc-friend-btn mc-friend-btn-green';
|
||||
joinBtn.textContent = '🎮 Вступить';
|
||||
joinBtn.onclick = () => {
|
||||
selectedWorldId = f.world_id;
|
||||
overlay.remove();
|
||||
styleEl.remove();
|
||||
resolve(selectedWorldId);
|
||||
};
|
||||
fDiv.appendChild(joinBtn);
|
||||
}
|
||||
body.appendChild(fDiv);
|
||||
}
|
||||
} else if (data.friends && !data.friends.length) {
|
||||
const noFriends = document.createElement('div');
|
||||
noFriends.className = 'mc-label';
|
||||
noFriends.style.color = '#888';
|
||||
noFriends.textContent = 'Пока нет друзей';
|
||||
body.appendChild(noFriends);
|
||||
}
|
||||
|
||||
// Pending friend requests
|
||||
if (data.pending && data.pending.length) {
|
||||
const pLabel = document.createElement('div');
|
||||
pLabel.className = 'mc-label';
|
||||
pLabel.style.color = '#B86E00';
|
||||
pLabel.textContent = '📨 Запросы в друзья:';
|
||||
body.appendChild(pLabel);
|
||||
for (const p of data.pending) {
|
||||
const pItem = document.createElement('div');
|
||||
pItem.className = 'mc-friend-row';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = p.from;
|
||||
pItem.appendChild(span);
|
||||
const btns = document.createElement('div');
|
||||
btns.style.display = 'flex';
|
||||
btns.style.gap = '4px';
|
||||
const acceptBtn = document.createElement('button');
|
||||
acceptBtn.className = 'mc-friend-btn mc-friend-btn-green';
|
||||
acceptBtn.textContent = '✓';
|
||||
acceptBtn.onclick = async () => {
|
||||
const r = await fetch(state.SERVER_URL + '/api/friends/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { showToast('✅ Друг добавлен!'); btnFriends.onclick(); } else { showToast('❌ Ошибка'); }
|
||||
};
|
||||
const declineBtn = document.createElement('button');
|
||||
declineBtn.className = 'mc-friend-btn mc-friend-btn-red';
|
||||
declineBtn.textContent = '✕';
|
||||
declineBtn.onclick = async () => {
|
||||
await fetch(state.SERVER_URL + '/api/friends/decline', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tg_id: tgUser.id, request_id: p.id })
|
||||
});
|
||||
showToast('Отклонено');
|
||||
btnFriends.onclick();
|
||||
};
|
||||
btns.appendChild(acceptBtn);
|
||||
btns.appendChild(declineBtn);
|
||||
pItem.appendChild(btns);
|
||||
body.appendChild(pItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Add friend
|
||||
body.appendChild(document.createElement('hr'));
|
||||
const addLabel = document.createElement('div');
|
||||
addLabel.className = 'mc-label';
|
||||
addLabel.textContent = '➕ Добавить друга по нику:';
|
||||
body.appendChild(addLabel);
|
||||
const addRow = document.createElement('div');
|
||||
addRow.style.display = 'flex';
|
||||
addRow.style.gap = '4px';
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'mc-input';
|
||||
addInput.style.flex = '1';
|
||||
addInput.type = 'text';
|
||||
addInput.maxLength = 16;
|
||||
addInput.placeholder = 'Ник друга';
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'mc-btn mc-btn-blue';
|
||||
addBtn.style.width = 'auto';
|
||||
addBtn.style.marginBottom = '0';
|
||||
addBtn.style.padding = '8px 16px';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.onclick = async () => {
|
||||
const nick = addInput.value.trim().toLowerCase();
|
||||
if (!nick || nick.length < 2) { showToast('❌ Минимум 2 символа'); return; }
|
||||
const r = await fetch(state.SERVER_URL + '/api/friends/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tg_id: tgUser.id, nickname: nick })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { showToast(d.message === 'Already friends' ? '✅ Уже друзья!' : '📨 Запрос отправлен!'); }
|
||||
else { showToast('❌ ' + (d.error || 'Ошибка')); }
|
||||
};
|
||||
addRow.appendChild(addInput);
|
||||
addRow.appendChild(addBtn);
|
||||
body.appendChild(addRow);
|
||||
|
||||
// Privacy checkboxes
|
||||
const settings = data.settings || { allow_requests: 1, notify_online: 1 };
|
||||
const settingsDiv = document.createElement('div');
|
||||
settingsDiv.style.marginTop = '12px';
|
||||
const reqLabel = document.createElement('label');
|
||||
reqLabel.className = 'mc-checkbox-row';
|
||||
const reqCb = document.createElement('input');
|
||||
reqCb.className = 'mc-checkbox';
|
||||
reqCb.type = 'checkbox';
|
||||
reqCb.checked = settings.allow_requests;
|
||||
reqCb.onchange = async () => {
|
||||
await fetch(state.SERVER_URL + '/api/friends/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tg_id: tgUser.id,
|
||||
allow_requests: reqCb.checked ? 1 : 0,
|
||||
notify_online: settings.notify_online
|
||||
})
|
||||
});
|
||||
};
|
||||
reqLabel.appendChild(reqCb);
|
||||
reqLabel.appendChild(document.createTextNode(' Запросы в друзья'));
|
||||
settingsDiv.appendChild(reqLabel);
|
||||
body.appendChild(settingsDiv);
|
||||
} catch (e) { showToast('❌ Ошибка загрузки друзей'); console.error(e); }
|
||||
};
|
||||
body.appendChild(btnFriends);
|
||||
}
|
||||
|
||||
// Player name input (only if not already set)
|
||||
if (!state.playerName) {
|
||||
const nameDivider = document.createElement('hr');
|
||||
nameDivider.className = 'mc-divider';
|
||||
body.appendChild(nameDivider);
|
||||
const nameLabel = document.createElement('div');
|
||||
nameLabel.className = 'mc-label';
|
||||
nameLabel.textContent = 'Твоё имя:';
|
||||
body.appendChild(nameLabel);
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'mc-input';
|
||||
nameInput.type = 'text';
|
||||
nameInput.placeholder = 'Ваше имя';
|
||||
nameInput.value = tgUser ? (tgUser.username || tgUser.first_name || '') : '';
|
||||
nameInput.maxLength = 20;
|
||||
nameInput.addEventListener('input', () => {
|
||||
if (nameInput.value.trim()) {
|
||||
state.playerName = nameInput.value.trim();
|
||||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||||
}
|
||||
});
|
||||
body.appendChild(nameInput);
|
||||
}
|
||||
|
||||
box.appendChild(body);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Pre-fill player name from TG if available
|
||||
if (isTgWebApp && tgUser && !state.playerName) {
|
||||
state.playerName = tgUser.username || tgUser.first_name || 'Игрок';
|
||||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||||
}
|
||||
|
||||
// Auto-load TG save info (background fetch)
|
||||
if (isTgWebApp && tgUser && !worldParam) {
|
||||
fetch(state.SERVER_URL + '/api/tg/save?tg_id=' + tgUser.id)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok && data.world_id) {
|
||||
// could update UI but menu is already shown
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,636 @@
|
|||
// Worms-Grechka: абстрактная симуляция боя. Ноль импортов, ноль DOM.
|
||||
// Один и тот же файл едет в клиент (хотсит) и в node-сервер (авторитет).
|
||||
// Сетка: адаптер { isSolid(gx,gy), isFluid(gx,gy), unbreakable(gx,gy),
|
||||
// remove(gx,gy)->bool, waterYPx, xMinPx, xMaxPx, killYPx }.
|
||||
const STILE = 40;
|
||||
const SGRAV = 2200; // как в Гречке
|
||||
const SMOVE = 320;
|
||||
const SJUMP = 760;
|
||||
const WORM_W = 34, WORM_H = 34;
|
||||
|
||||
const WEAPONS = {
|
||||
bazooka: { name: 'Базука', kind: 'aimed', power: true, windK: 1.0, dmg: 45, blast: 2.3, ammo: Infinity },
|
||||
homing: { name: 'Самонавод', kind: 'target', power: false, windK: 0, dmg: 50, blast: 2.3, ammo: 2 },
|
||||
sheep: { name: 'Овца', kind: 'sheep', power: false, windK: 0, dmg: 55, blast: 2.6, ammo: 1 },
|
||||
grenade: { name: 'Граната', kind: 'thrown', power: true, windK: 0.25, dmg: 40, blast: 2.1, fuse: [1, 2, 3, 4, 5], ammo: Infinity },
|
||||
cluster: { name: 'Кластер', kind: 'thrown', power: true, windK: 0.5, dmg: 26, blast: 1.5, fuse: [2, 3, 4], ammo: 3 },
|
||||
shotgun: { name: 'Дробовик', kind: 'hitscan', shots: 2, windK: 0, dmg: 6, pellets: 5, ammo: 3 },
|
||||
uzi: { name: 'Узи', kind: 'spray', windK: 0, dmg: 4, pellets: 12, ammo: 3 },
|
||||
dynamite: { name: 'Динамит', kind: 'drop', windK: 0, dmg: 70, blast: 3.6, fuse: 5, ammo: 2 },
|
||||
mine: { name: 'Мина', kind: 'drop', windK: 0, dmg: 45, blast: 2.2, ammo: 3 },
|
||||
airstrike:{ name: 'Авиаудар', kind: 'target', windK: 0, dmg: 35, blast: 1.7, ammo: 2 },
|
||||
teleport: { name: 'Телепорт', kind: 'teleport', windK: 0, ammo: 2 },
|
||||
shovel: { name: 'Лопата', kind: 'dig', windK: 0, ammo: Infinity },
|
||||
skip: { name: 'Пропуск', kind: 'skip', windK: 0, ammo: Infinity },
|
||||
};
|
||||
|
||||
const TURN_TIME = 30;
|
||||
const RETREAT_TIME = 3;
|
||||
const PROJ_GRAV = 950; // снаряды более «плавучие», чем червяки
|
||||
const WIND_ACCEL = 55; // px/s^2 на единицу ветра
|
||||
const MAX_WIND = 5;
|
||||
|
||||
function mulberry(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
class Sim {
|
||||
constructor(grid, seed) {
|
||||
this.g = grid;
|
||||
this.rand = mulberry(seed >>> 0);
|
||||
this.worms = [];
|
||||
this.proj = [];
|
||||
this.mines = [];
|
||||
this.dyn = [];
|
||||
this.strikes = [];
|
||||
this.events = [];
|
||||
this.wind = 0;
|
||||
this._wid = 1; this._pid = 1;
|
||||
}
|
||||
ev(e) { this.events.push(e); }
|
||||
drainEvents() { const e = this.events; this.events = []; return e; }
|
||||
|
||||
addWorm(team, name, x, y) {
|
||||
const w = {
|
||||
id: this._wid++, team, name, x, y, w: WORM_W, h: WORM_H,
|
||||
vx: 0, vy: 0, dir: team === 0 ? 1 : -1,
|
||||
hp: 100, alive: true, grounded: false, moveDir: 0, fallStartY: y,
|
||||
inWater: false, o2: 100, o2acc: 0,
|
||||
};
|
||||
this.worms.push(w);
|
||||
return w;
|
||||
}
|
||||
wormById(id) { return this.worms.find((w) => w.id === id); }
|
||||
aliveTeams() { const s = new Set(); for (const w of this.worms) if (w.alive) s.add(w.team); return [...s]; }
|
||||
|
||||
// --- тайловая AABB-физика в стиле Гречки ---
|
||||
solidBox(x, y, w, h) {
|
||||
const g = this.g;
|
||||
const gx0 = Math.floor(x / STILE), gx1 = Math.floor((x + w - 1) / STILE);
|
||||
const gy0 = Math.floor(y / STILE), gy1 = Math.floor((y + h - 1) / STILE);
|
||||
for (let gy = gy0; gy <= gy1; gy++)
|
||||
for (let gx = gx0; gx <= gx1; gx++)
|
||||
if (g.isSolid(gx, gy)) return true;
|
||||
return false;
|
||||
}
|
||||
fluidBox(x, y, w, h) {
|
||||
const g = this.g;
|
||||
const gx0 = Math.floor(x / STILE), gx1 = Math.floor((x + w - 1) / STILE);
|
||||
const gy0 = Math.floor(y / STILE), gy1 = Math.floor((y + h - 1) / STILE);
|
||||
for (let gy = gy0; gy <= gy1; gy++)
|
||||
for (let gx = gx0; gx <= gx1; gx++)
|
||||
if (g.isFluid(gx, gy)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
setMove(id, dir) { const w = this.wormById(id); if (w && w.alive) { w.moveDir = dir; if (dir) w.dir = dir; } }
|
||||
jump(id) {
|
||||
const w = this.wormById(id);
|
||||
if (w && w.alive && (w.grounded || w.inWater)) {
|
||||
w.vy = w.inWater ? -SJUMP * 0.55 : -SJUMP; // в воде — гребок вверх
|
||||
w.grounded = false;
|
||||
}
|
||||
}
|
||||
|
||||
hurt(w, dmg, cause) {
|
||||
if (!w.alive) return;
|
||||
w.hp -= dmg;
|
||||
this.ev({ t: 'damage', worm: w.id, dmg: Math.round(dmg), hp: Math.max(0, Math.round(w.hp)) });
|
||||
if (w.hp <= 0) this.kill(w, cause);
|
||||
}
|
||||
kill(w, cause) {
|
||||
if (!w.alive) return;
|
||||
w.alive = false; w.hp = 0; w.moveDir = 0;
|
||||
this.ev({ t: 'die', worm: w.id, team: w.team, cause: cause || 'killed', x: Math.round(w.x), y: Math.round(w.y) });
|
||||
}
|
||||
|
||||
explodePx(x, y, dmg, blastTiles, cause) {
|
||||
const g = this.g;
|
||||
const R = blastTiles * STILE;
|
||||
// разрушение: круг по тайлам (жидкости и неразрушаемое не трогаем)
|
||||
const removed = [];
|
||||
const gx0 = Math.floor((x - R) / STILE), gx1 = Math.floor((x + R) / STILE);
|
||||
const gy0 = Math.floor((y - R) / STILE), gy1 = Math.floor((y + R) / STILE);
|
||||
for (let gy = gy0; gy <= gy1; gy++)
|
||||
for (let gx = gx0; gx <= gx1; gx++) {
|
||||
const cx = gx * STILE + STILE / 2, cy = gy * STILE + STILE / 2;
|
||||
if (Math.hypot(cx - x, cy - y) > R) continue;
|
||||
// взрыв на суше не роет ниже уровня моря — в воду ничего не падает
|
||||
if (y < g.waterYPx && gy * STILE > g.waterYPx) continue;
|
||||
if (g.isFluid(gx, gy) || g.unbreakable(gx, gy)) continue;
|
||||
if (g.remove(gx, gy)) removed.push([gx, gy]);
|
||||
}
|
||||
this.ev({ t: 'carve', x: Math.round(x), y: Math.round(y), r: Math.round(R), tiles: removed });
|
||||
this.ev({ t: 'explode', x: Math.round(x), y: Math.round(y), r: Math.round(R), dmg });
|
||||
// урон + отброс
|
||||
for (const w of this.worms) {
|
||||
if (!w.alive) continue;
|
||||
const dx = (w.x + w.w / 2) - x, dy = (w.y + w.h / 2) - y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < R + 60) {
|
||||
const dd = Math.max(1, Math.round(dmg * (1 - dist / (R + 60))));
|
||||
this.hurt(w, dd, cause || 'boom');
|
||||
// отброс с ограничением: серия взрывов не катапультирует через полкарты в воду
|
||||
w.vx = Math.max(-450, Math.min(450, w.vx + (dx / (dist || 1)) * 620));
|
||||
w.vy = Math.max(-450, w.vy - 330);
|
||||
w.grounded = false;
|
||||
}
|
||||
}
|
||||
// цепные реакции
|
||||
for (const m of this.mines)
|
||||
if (m.fuse <= 0 && Math.hypot(m.x - x, m.y - y) < R) { m.fuse = 0.25; m.armed = 1; }
|
||||
for (const d of this.dyn)
|
||||
if (Math.hypot(d.x - x, d.y - y) < R) d.fuse = Math.min(d.fuse, 0.4);
|
||||
for (const p of this.proj)
|
||||
if ((p.kind === 'grenade' || p.kind === 'cluster') && Math.hypot(p.x - x, p.y - y) < R)
|
||||
p.fuse = Math.min(p.fuse, 0.05);
|
||||
}
|
||||
|
||||
fire(wormId, weaponId, p) {
|
||||
const w = this.wormById(wormId);
|
||||
const def = WEAPONS[weaponId];
|
||||
if (!w || !w.alive || !def) return false;
|
||||
p = p || {};
|
||||
const ang = p.angle || 0;
|
||||
const pow = Math.max(0, Math.min(1, p.power == null ? 0.5 : p.power));
|
||||
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
|
||||
const mx = cx + Math.cos(ang) * 26, my = cy - 6 + Math.sin(ang) * 26;
|
||||
const P = (kind, x, y, vx, vy, extra) => Object.assign({ id: this._pid++, kind, x, y, vx, vy, age: 0 }, extra || {});
|
||||
switch (def.kind) {
|
||||
case 'aimed': {
|
||||
const sp = 500 + pow * 1000;
|
||||
this.proj.push(P(weaponId === 'homing' ? 'homing' : 'missile', mx, my, Math.cos(ang) * sp, Math.sin(ang) * sp,
|
||||
{ dmg: def.dmg, blast: def.blast, windK: def.windK, owner: w.id, team: w.team, life: weaponId === 'homing' ? 5 : 0 }));
|
||||
break;
|
||||
}
|
||||
case 'sheep': {
|
||||
// овцу выпускаем у ног — бежит сама, прыгает через уступы, разворачивается у стен
|
||||
this.proj.push(P('sheep', cx + w.dir * 24, cy + 10, w.dir * 60, -60,
|
||||
{ dmg: def.dmg, blast: def.blast, fuse: 4, owner: w.id, team: w.team, dir: w.dir }));
|
||||
break;
|
||||
}
|
||||
case 'thrown': {
|
||||
const sp = 300 + pow * 800;
|
||||
const fuse = p.fuse || (def.fuse ? def.fuse[2] : 3);
|
||||
this.proj.push(P(weaponId === 'cluster' ? 'cluster' : 'grenade', cx, cy - 20,
|
||||
w.dir * 60 + Math.cos(ang) * sp * 0.7, Math.sin(ang) * sp - 140,
|
||||
{ dmg: def.dmg, blast: def.blast, windK: def.windK, fuse, owner: w.id, team: w.team }));
|
||||
break;
|
||||
}
|
||||
case 'hitscan': {
|
||||
for (let i = 0; i < def.pellets; i++) {
|
||||
const a = ang + (this.rand() - 0.5) * 0.14;
|
||||
this.proj.push(P('pellet', mx, my, Math.cos(a) * 1500, Math.sin(a) * 1500,
|
||||
{ dmg: def.dmg, life: 1.6, owner: w.id, team: w.team }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'spray': {
|
||||
for (let i = 0; i < def.pellets; i++) {
|
||||
const a = ang + (this.rand() - 0.5) * 0.26;
|
||||
const sp = 1250 + this.rand() * 250;
|
||||
this.proj.push(P('pellet', mx, my, Math.cos(a) * sp, Math.sin(a) * sp,
|
||||
{ dmg: def.dmg, life: 1.4, owner: w.id, team: w.team }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'drop': {
|
||||
if (weaponId === 'dynamite') this.dyn.push({ x: cx + w.dir * 30, y: cy, fuse: def.fuse });
|
||||
else this.mines.push({ x: cx + w.dir * 30, y: cy, team: w.team, armed: 0, fuse: 0 });
|
||||
break;
|
||||
}
|
||||
case 'target': {
|
||||
const tx = Math.max(this.g.xMinPx + 30, Math.min(this.g.xMaxPx - 30, p.x || (cx + w.dir * 200)));
|
||||
if (weaponId === 'homing') {
|
||||
// самонавод: ракета вылетает и летит в отмеченную крестиком точку.
|
||||
// стартуем из первой свободной клетки вдоль прицела — иначе нос в стене = взрыв в упор
|
||||
const tgtY = p.y == null ? cy : p.y;
|
||||
const a0 = Math.atan2(tgtY - my, tx - mx);
|
||||
let hx = mx, hy = my;
|
||||
for (let d = 8; d <= 80; d += 8) {
|
||||
const qx = mx + Math.cos(a0) * d, qy = my + Math.sin(a0) * d;
|
||||
if (!this.pointSolid(qx, qy)) { hx = qx; hy = qy; break; }
|
||||
}
|
||||
this.proj.push(P('homing', hx, hy, Math.cos(a0) * 950, Math.sin(a0) * 950,
|
||||
{ dmg: def.dmg, blast: def.blast, windK: 0, owner: w.id, team: w.team, life: 6, tx, ty: tgtY }));
|
||||
} else {
|
||||
this.strikes.push({ x: tx, t: 0.8, left: 5, next: 0, dir: this.rand() < 0.5 ? -1 : 1, px: 0, py: 0, started: false, team: w.team });
|
||||
}
|
||||
this.ev({ t: 'strike_incoming', x: Math.round(tx) });
|
||||
break;
|
||||
}
|
||||
case 'teleport': {
|
||||
const tx = Math.max(this.g.xMinPx + 20, Math.min(this.g.xMaxPx - 20, p.x || cx));
|
||||
// ищем землю под точкой
|
||||
const gy = this.g.groundBelowPx(tx, p.y == null ? 0 : p.y);
|
||||
if (gy !== null) {
|
||||
w.x = tx - w.w / 2; w.y = gy - w.h - 1; w.vx = 0; w.vy = 0;
|
||||
this.ev({ t: 'teleport', worm: w.id, x: Math.round(w.x), y: Math.round(w.y) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dig': {
|
||||
// лопата: рейкаст вдоль прицела — копаем первую твёрдую землю (вперёд/назад/вниз)
|
||||
let hx = null, hy = null;
|
||||
const dx = Math.cos(ang), dy = Math.sin(ang);
|
||||
for (let d = 8; d <= 95 && hx == null; d += 8) {
|
||||
const gx = Math.floor((cx + dx * d) / STILE), gy = Math.floor((cy + dy * d) / STILE);
|
||||
if (this.g.unbreakable(gx, gy) || this.g.isFluid(gx, gy)) continue;
|
||||
if (this.g.isSolid ? this.g.isSolid(gx, gy) : true) { hx = gx; hy = gy; }
|
||||
}
|
||||
// не зацепили — опускаемся от точки прицела вниз до земли
|
||||
let px = cx + dx * 52, py = cy + dy * 52;
|
||||
if (hx == null) {
|
||||
for (let dd = 0; dd <= 120; dd += 10) {
|
||||
const gx = Math.floor(px / STILE), gy = Math.floor((py + dd) / STILE);
|
||||
if (this.g.unbreakable(gx, gy) || this.g.isFluid(gx, gy)) continue;
|
||||
if (this.g.isSolid ? this.g.isSolid(gx, gy) : true) { px = gx * STILE + STILE / 2; py = gy * STILE + STILE / 2; hx = gx; hy = gy; break; }
|
||||
}
|
||||
if (hx == null) return false; // рядом нет земли — пустой взмах
|
||||
} else { px = hx * STILE + STILE / 2; py = hy * STILE + STILE / 2; }
|
||||
const R = STILE * 1.6; // тоннели просторные — внутри можно ходить и прыгать
|
||||
let dug = 0;
|
||||
const gx0 = Math.floor((px - R) / STILE), gx1 = Math.floor((px + R) / STILE);
|
||||
const gy0 = Math.floor((py - R) / STILE), gy1 = Math.floor((py + R) / STILE);
|
||||
for (let gy = gy0; gy <= gy1; gy++)
|
||||
for (let gx = gx0; gx <= gx1; gx++) {
|
||||
const bx = gx * STILE + STILE / 2, by = gy * STILE + STILE / 2;
|
||||
if (Math.hypot(bx - px, by - py) > R) continue;
|
||||
if (this.g.isFluid(gx, gy) || this.g.unbreakable(gx, gy)) continue;
|
||||
if (this.g.remove(gx, gy)) dug++;
|
||||
}
|
||||
if (dug > 0) this.ev({ t: 'dig', x: Math.round(px), y: Math.round(py) });
|
||||
return dug > 0;
|
||||
}
|
||||
case 'skip': break;
|
||||
}
|
||||
w.moveDir = 0;
|
||||
this.ev({ t: 'fired', worm: w.id, weapon: weaponId });
|
||||
return true;
|
||||
}
|
||||
|
||||
step(dt) {
|
||||
const g = this.g;
|
||||
// --- червяки ---
|
||||
for (const w of this.worms) {
|
||||
if (!w.alive) continue;
|
||||
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
|
||||
// вода: можно плыть (как в Гречке), смерть только от нехватки кислорода
|
||||
const wasWater = w.inWater;
|
||||
w.inWater = this.fluidBox(w.x + 4, w.y + 4, w.w - 8, w.h - 8);
|
||||
if (w.inWater && !wasWater) this.ev({ t: 'splash', x: Math.round(cx) });
|
||||
const headWet = this.fluidBox(w.x + 8, w.y, w.w - 16, 14);
|
||||
if (headWet) {
|
||||
w.o2 = Math.max(0, w.o2 - 14 * dt);
|
||||
if (w.o2 <= 0) {
|
||||
w.o2acc += 12 * dt;
|
||||
if (w.o2acc >= 5) { w.o2acc -= 5; this.hurt(w, 5, 'drown'); }
|
||||
}
|
||||
} else w.o2 = Math.min(100, w.o2 + 50 * dt);
|
||||
if (w.y > g.killYPx) { this.kill(w, 'out'); continue; }
|
||||
// горизонталь (в воде медленнее)
|
||||
const spd = w.inWater ? SMOVE * 0.5 : SMOVE;
|
||||
w.vx = w.moveDir * spd;
|
||||
if (w.vx !== 0) {
|
||||
let nx = w.x + w.vx * dt;
|
||||
nx = Math.max(g.xMinPx, Math.min(g.xMaxPx - w.w, nx));
|
||||
if (!this.solidBox(nx, w.y + 2, w.w, w.h - 2)) w.x = nx;
|
||||
else {
|
||||
// подъём по склону до 1 тайла
|
||||
let stepped = false;
|
||||
for (let k = 8; k <= STILE; k += 8) {
|
||||
if (!this.solidBox(nx, w.y - k, w.w, w.h)) { w.x = nx; w.y -= k; stepped = true; break; }
|
||||
}
|
||||
if (!stepped) w.vx = 0;
|
||||
}
|
||||
}
|
||||
// вертикаль (в воде — плавучесть как в Гречке)
|
||||
w.vy += (w.inWater ? 550 : SGRAV) * dt;
|
||||
if (w.inWater && w.vy > 260) w.vy = 260;
|
||||
if (w.vy > 1600) w.vy = 1600;
|
||||
let ny = w.y + w.vy * dt;
|
||||
if (w.vy >= 0) {
|
||||
// опора под ногами
|
||||
if (this.solidBox(w.x + 2, ny + w.h - 6, w.w - 4, 8)) {
|
||||
const gy = Math.floor((ny + w.h) / STILE);
|
||||
w.y = gy * STILE - w.h;
|
||||
const fallTiles = (w.y - w.fallStartY) / STILE;
|
||||
if (fallTiles > 6 && !this.fluidBox(w.x, w.y, w.w, w.h)) { console.log('[DBG] fall worm=' + w.id + ' tiles=' + fallTiles.toFixed(1)); this.hurt(w, (fallTiles - 6) * 10, 'fall'); }
|
||||
w.vy = 0; w.grounded = true; w.fallStartY = w.y;
|
||||
} else { w.y = ny; w.grounded = false; }
|
||||
} else {
|
||||
if (this.solidBox(w.x + 2, ny, w.w - 4, 8)) { w.vy = 0; }
|
||||
else { w.y = ny; w.grounded = false; }
|
||||
}
|
||||
if (!w.grounded && this.solidBox(w.x + 2, w.y + w.h, w.w - 4, 3)) {
|
||||
w.grounded = true; w.vy = 0; w.fallStartY = w.y;
|
||||
}
|
||||
}
|
||||
// --- мины ---
|
||||
for (const m of this.mines) {
|
||||
if (m.armed < 1 && m.fuse <= 0) m.armed = Math.min(1, m.armed + dt / 2);
|
||||
else if (m.fuse > 0) {
|
||||
m.fuse -= dt;
|
||||
if (m.fuse <= 0) { this.explodePx(m.x, m.y, WEAPONS.mine.dmg, WEAPONS.mine.blast, 'mine'); m.dead = true; }
|
||||
} else {
|
||||
for (const w of this.worms) {
|
||||
if (w.alive && w.team !== m.team &&
|
||||
Math.hypot(w.x + w.w / 2 - m.x, w.y + w.h / 2 - m.y) < 46) {
|
||||
m.fuse = 0.8; this.ev({ t: 'mine_beep', x: Math.round(m.x), y: Math.round(m.y) }); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.mines = this.mines.filter((m) => !m.dead);
|
||||
// --- динамит ---
|
||||
for (const d of this.dyn) {
|
||||
d.fuse -= dt;
|
||||
if (d.fuse <= 0) { this.explodePx(d.x, d.y, WEAPONS.dynamite.dmg, WEAPONS.dynamite.blast, 'dynamite'); d.dead = true; }
|
||||
}
|
||||
this.dyn = this.dyn.filter((d) => !d.dead);
|
||||
// --- авиаудары: пиксельный самолёт пролетает и сбрасывает бомбы ---
|
||||
for (const s of this.strikes) {
|
||||
if (!s.started) {
|
||||
s.t -= dt;
|
||||
if (s.t <= 0) {
|
||||
s.started = true;
|
||||
s.px = s.dir > 0 ? this.g.xMinPx - 300 : this.g.xMaxPx + 300;
|
||||
s.py = -170; // выше самых высоких деревьев на пиках (пик 0 + дерево 80 + запас)
|
||||
}
|
||||
continue;
|
||||
}
|
||||
s.px += s.dir * 560 * dt;
|
||||
const inZone = Math.abs(s.px - s.x) < 260;
|
||||
const passed = s.dir > 0 ? s.px > s.x + 200 : s.px < s.x - 200;
|
||||
if (s.left > 0 && (inZone || passed)) {
|
||||
s.next -= dt;
|
||||
if (s.next <= 0) {
|
||||
s.next = 0.16; s.left--;
|
||||
this.proj.push({ id: this._pid++, kind: 'striker', x: s.px, y: s.py + 10, vx: s.dir * 120, vy: 200, age: 0, dmg: WEAPONS.airstrike.dmg, blast: WEAPONS.airstrike.blast, windK: 0, team: s.team });
|
||||
}
|
||||
}
|
||||
if (s.left <= 0 && Math.abs(s.px - s.x) > 600) s.dead = true;
|
||||
}
|
||||
this.strikes = this.strikes.filter((s) => !s.dead);
|
||||
// --- снаряды ---
|
||||
for (const p of this.proj) {
|
||||
p.age += dt;
|
||||
if (p.kind === 'pellet') {
|
||||
p.life -= dt;
|
||||
// субстепы ≤10px: иначе быстрая пуля пролетает червяка насквозь
|
||||
const dist = Math.hypot(p.vx * dt, p.vy * dt);
|
||||
const n = Math.max(1, Math.ceil(dist / 10));
|
||||
for (let k = 0; k < n && !p.dead; k++) {
|
||||
if (p.life <= 0 || this.advance(p, dt / n)) { p.dead = true; break; }
|
||||
for (const w of this.worms) {
|
||||
if (!w.alive || (w.id === p.owner && p.age < 0.05)) continue;
|
||||
if (p.x > w.x && p.x < w.x + w.w && p.y > w.y && p.y < w.y + w.h) {
|
||||
this.hurt(w, p.dmg, 'shot');
|
||||
w.vx = 0; w.vy = -120; w.grounded = false;
|
||||
p.dead = true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (p.dead) continue;
|
||||
} else if (p.kind === 'striker') {
|
||||
p.vy += PROJ_GRAV * 0.5 * dt;
|
||||
if (this.advance(p, dt)) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'air'); p.dead = true; }
|
||||
} else if (p.kind === 'missile') {
|
||||
p.vx += this.wind * WIND_ACCEL * (p.windK || 0) * dt;
|
||||
p.vy += PROJ_GRAV * dt;
|
||||
if (this.advance(p, dt)) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); p.dead = true; continue; }
|
||||
// вода — только настоящая (клетка), а не «всё ниже уровня моря»: сухие катакомбы под морем — не вода
|
||||
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
|
||||
// упала в воду — взрыв на поверхности, а не тихое бульк
|
||||
this.ev({ t: 'splash', x: Math.round(p.x) });
|
||||
this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka');
|
||||
p.dead = true; continue;
|
||||
}
|
||||
for (const w of this.worms) {
|
||||
if (!w.alive || (w.id === p.owner && p.age < 0.1)) continue;
|
||||
if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 26) {
|
||||
this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); p.dead = true; break;
|
||||
}
|
||||
}
|
||||
} else if (p.kind === 'homing') {
|
||||
// самонаводящаяся ракета: летит в отмеченную крестиком точку (или доворачивает к врагу рядом с ней)
|
||||
p.life -= dt;
|
||||
let tgt = null, bd = 1e9;
|
||||
for (const w of this.worms) {
|
||||
if (!w.alive || w.team === p.team) continue;
|
||||
const d = Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y);
|
||||
if (d < bd) { bd = d; tgt = w; }
|
||||
}
|
||||
// враг далеко от точки — летим строго в точку; рядом с точкой — доворачиваем к нему
|
||||
const tdx = (p.tx == null ? p.x : p.tx), tdy = (p.ty == null ? p.y : p.ty);
|
||||
let gx = tdx, gy = tdy;
|
||||
if (tgt && Math.hypot(tgt.x - tdx, tgt.y - tdy) < 120) { gx = tgt.x + tgt.w / 2; gy = tgt.y + tgt.h / 2; }
|
||||
const SPD = 950;
|
||||
{
|
||||
const cur = Math.atan2(p.vy, p.vx);
|
||||
const want = Math.atan2(gy - p.y, gx - p.x);
|
||||
let d = want - cur;
|
||||
while (d > Math.PI) d -= Math.PI * 2;
|
||||
while (d < -Math.PI) d += Math.PI * 2;
|
||||
const turn = 3.4 * dt, na = cur + Math.max(-turn, Math.min(turn, d));
|
||||
p.vx = Math.cos(na) * SPD; p.vy = Math.sin(na) * SPD;
|
||||
}
|
||||
if (Math.hypot(gx - p.x, gy - p.y) < 30) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; }
|
||||
if (this.advance(p, dt)) {
|
||||
if (p.age < 0.12) continue; // только вылетела — нос ещё выходит из-за укрытия, не взрываем
|
||||
this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue;
|
||||
}
|
||||
if (p.life <= 0) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; }
|
||||
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
|
||||
this.ev({ t: 'splash', x: Math.round(p.x) });
|
||||
this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing');
|
||||
p.dead = true; continue;
|
||||
}
|
||||
for (const w of this.worms) {
|
||||
if (!w.alive || (w.id === p.owner && p.age < 0.1)) continue;
|
||||
if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 26) {
|
||||
this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; break;
|
||||
}
|
||||
}
|
||||
} else if (p.kind === 'sheep') {
|
||||
// супер-овца: бежит по земле, вскарабкивается на уступы, у стен разворачивается, у врага — бум
|
||||
p.fuse -= dt;
|
||||
p.vy = Math.min(900, p.vy + SGRAV * dt);
|
||||
const dir = p.dir || 1, nx = p.x + dir * 150 * dt;
|
||||
if (!this.pointSolid(nx, p.y)) p.x = nx;
|
||||
else if (!this.pointSolid(nx, p.y - 34)) { p.x = nx; p.y -= 34; p.vy = Math.min(0, p.vy); }
|
||||
else p.dir = -dir;
|
||||
p.y += p.vy * dt;
|
||||
if (this.pointSolid(p.x, p.y + 10)) {
|
||||
let n = 0;
|
||||
while (n++ < 20 && this.pointSolid(p.x, p.y + 8)) p.y -= 2;
|
||||
p.vy = 0;
|
||||
}
|
||||
if (p.y > this.g.killYPx) { p.dead = true; continue; }
|
||||
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
|
||||
this.ev({ t: 'splash', x: Math.round(p.x) });
|
||||
this.explodePx(p.x, p.y, p.dmg, p.blast, 'sheep');
|
||||
p.dead = true; continue;
|
||||
}
|
||||
let hit = p.fuse <= 0;
|
||||
if (!hit) for (const w of this.worms) {
|
||||
if (!w.alive || w.team === p.team) continue; // своих (включая хозяина) не задевает — только враги
|
||||
if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 30) { hit = true; break; }
|
||||
}
|
||||
if (hit) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'sheep'); p.dead = true; }
|
||||
} else { // grenade / cluster
|
||||
p.vx += this.wind * WIND_ACCEL * (p.windK || 0) * dt;
|
||||
p.vy += PROJ_GRAV * dt;
|
||||
this.bounce(p, dt);
|
||||
p.fuse -= dt;
|
||||
// вода — только настоящая клетка: в сухих катакомбах под морем граната летит дальше
|
||||
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
|
||||
// граната/кластер в воде — взрыв на месте + осколки, а не тихое бульк
|
||||
this.ev({ t: 'splash', x: Math.round(p.x) });
|
||||
const cy = p.y;
|
||||
if (p.kind === 'cluster') {
|
||||
this.explodePx(p.x, cy, 10, 1.1, 'cluster');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const a = (i / 5) * Math.PI * 2 + this.rand() * 0.6;
|
||||
this.proj.push({ id: this._pid++, kind: 'grenade', x: p.x, y: cy - 4, vx: Math.cos(a) * (160 + this.rand() * 180), vy: Math.sin(a) * 240 - 180, age: 0, dmg: p.dmg, blast: WEAPONS.cluster.blast, windK: 0.3, fuse: 1.0, owner: p.owner });
|
||||
}
|
||||
} else this.explodePx(p.x, cy, p.dmg, p.blast, 'grenade');
|
||||
p.dead = true; continue;
|
||||
}
|
||||
if (p.fuse <= 0) {
|
||||
if (p.kind === 'cluster') {
|
||||
this.explodePx(p.x, p.y, 10, 1.1, 'cluster');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const a = (i / 5) * Math.PI * 2 + this.rand() * 0.6;
|
||||
this.proj.push({ id: this._pid++, kind: 'grenade', x: p.x, y: p.y - 4, vx: Math.cos(a) * (160 + this.rand() * 180), vy: Math.sin(a) * 240 - 180, age: 0, dmg: p.dmg, blast: WEAPONS.cluster.blast, windK: 0.3, fuse: 1.0, owner: p.owner });
|
||||
}
|
||||
} else this.explodePx(p.x, p.y, p.dmg, p.blast, 'grenade');
|
||||
p.dead = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.proj = this.proj.filter((p) => !p.dead);
|
||||
}
|
||||
|
||||
pointSolid(x, y) {
|
||||
return this.g.isSolid(Math.floor(x / STILE), Math.floor(y / STILE));
|
||||
}
|
||||
advance(p, dt) {
|
||||
// Возвращает true при ударе о землю (dead НЕ ставит — решает вызывающий:
|
||||
// пуля гаснет, базука взрывается).
|
||||
const dist = Math.hypot(p.vx * dt, p.vy * dt);
|
||||
const n = Math.max(1, Math.ceil(dist / 6));
|
||||
for (let i = 0; i < n; i++) {
|
||||
p.x += (p.vx * dt) / n; p.y += (p.vy * dt) / n;
|
||||
if (this.pointSolid(p.x, p.y)) { p.hitX = p.x; p.hitY = p.y; return true; }
|
||||
if (p.x < this.g.xMinPx + 2 || p.x > this.g.xMaxPx - 2) { p.vx = -p.vx; p.x = Math.max(this.g.xMinPx + 2, Math.min(this.g.xMaxPx - 2, p.x)); }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bounce(p, dt) {
|
||||
const dist = Math.hypot(p.vx * dt, p.vy * dt);
|
||||
const n = Math.max(1, Math.ceil(dist / 5));
|
||||
let hitX = false, hitY = false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const sx = (p.vx * dt) / n, sy = (p.vy * dt) / n;
|
||||
if (!this.pointSolid(p.x + sx, p.y)) p.x += sx; else hitX = true;
|
||||
if (!this.pointSolid(p.x, p.y + sy)) p.y += sy; else hitY = true;
|
||||
}
|
||||
// отклик — один раз за шаг, а не за каждый подшаг: иначе касание склона
|
||||
// на скорости гасит скорость в ноль и граната падает сильно ближе прицела
|
||||
if (hitX) p.vx = -p.vx * 0.45;
|
||||
if (hitY) { p.vy = -p.vy * 0.42; p.vx *= 0.82; if (Math.abs(p.vy) < 70) p.vy = 0; }
|
||||
if (p.vy === 0 && Math.abs(p.vx) < 30) p.vx = 0;
|
||||
if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy) * 0.5; }
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
worms: this.worms.map((w) => ({ id: w.id, team: w.team, name: w.name, x: Math.round(w.x * 10) / 10, y: Math.round(w.y * 10) / 10, dir: w.dir, hp: Math.round(w.hp), alive: w.alive, swim: w.inWater, o2: Math.round(w.o2) })),
|
||||
proj: this.proj.map((p) => ({ id: p.id, k: p.kind, x: Math.round(p.x), y: Math.round(p.y), vx: Math.round(p.vx || 0), vy: Math.round(p.vy || 0), t: p.team, o: p.owner })),
|
||||
mines: this.mines.map((m) => ({ x: Math.round(m.x), y: Math.round(m.y), armed: m.armed >= 1, beep: m.fuse > 0 })),
|
||||
dyn: this.dyn.map((d) => ({ x: Math.round(d.x), y: Math.round(d.y) })),
|
||||
strikes: this.strikes.length,
|
||||
planes: this.strikes.filter((s) => s.started && !s.dead).map((s) => ({ x: Math.round(s.px), y: Math.round(s.py), d: s.dir, t: s.team })),
|
||||
wind: this.wind,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Перебор углов/сил через превью: куда упадёт снаряд ближе к цели.
|
||||
// Чистая функция — используется и ИИ в клиенте, и тестами.
|
||||
function solveShot(grid, mx, my, tx, ty, thrown, wind, dir) {
|
||||
let best = { ang: -1.1, pow: 0.6, err: 1e12 };
|
||||
const angs = thrown ? [-2.4, -2.0, -1.6, -1.2, -0.8] : [-2.5, -2.1, -1.7, -1.3, -0.9, -0.5];
|
||||
const pows = [0.3, 0.5, 0.7, 0.9, 1.0];
|
||||
for (const a of angs) {
|
||||
for (const p of pows) {
|
||||
const sx = mx + Math.cos(a) * 26, sy = my + Math.sin(a) * 26;
|
||||
const pts = previewShot(grid, sx, sy, a, p,
|
||||
{ thrown, wind, windK: thrown ? 0.25 : 1.0, fuse: 4, dir: dir || 1 });
|
||||
if (!pts.length) continue;
|
||||
const land = pts[pts.length - 1];
|
||||
const err = Math.hypot(land[0] - tx, land[1] - ty) + (thrown ? 0 : Math.abs(a + 1.2) * 30);
|
||||
if (err < best.err) best = { ang: a, pow: p, err };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Превью траектории для прицела (только aimed/thrown, без взрывов по пути).
|
||||
function previewStraight(grid, sx, sy, angle, maxLen) {
|
||||
// прямой прицел дроби/УЗИ: луч без гравитации до земли или лимита
|
||||
const pts = [];
|
||||
const dx = Math.cos(angle), dy = Math.sin(angle);
|
||||
for (let d = 24; d <= (maxLen || 520); d += 24) {
|
||||
const x = sx + dx * d, y = sy + dy * d;
|
||||
pts.push([Math.round(x), Math.round(y)]);
|
||||
if (grid.isSolid(Math.floor(x / STILE), Math.floor(y / STILE))) break;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
function previewShot(grid, sx, sy, angle, power, opts) {
|
||||
opts = opts || {};
|
||||
const pts = [];
|
||||
let x = sx, y = sy;
|
||||
let vx, vy;
|
||||
if (opts.thrown) {
|
||||
const sp = 300 + power * 800;
|
||||
vx = (opts.dir || 1) * 60 + Math.cos(angle) * sp * 0.7;
|
||||
vy = Math.sin(angle) * sp - 140;
|
||||
} else {
|
||||
vx = Math.cos(angle) * (500 + power * 1000);
|
||||
vy = Math.sin(angle) * (500 + power * 1000);
|
||||
}
|
||||
const dt = 1 / 60;
|
||||
const windAcc = (opts.wind || 0) * WIND_ACCEL * (opts.windK || 0);
|
||||
const maxT = opts.fuse || 4;
|
||||
const solid = (x, y) => grid.isSolid(Math.floor(x / STILE), Math.floor(y / STILE));
|
||||
const fluid = (x, y) => grid.isFluid(Math.floor(x / STILE), Math.floor(y / STILE));
|
||||
for (let t = 0; t < maxT; t += dt) {
|
||||
vx += windAcc * dt; vy += PROJ_GRAV * dt;
|
||||
// подшаги как в bounce/advance сима: иначе на скорости превью проваливается
|
||||
// в склон и рисует точку падения дальше, чем долетит граната
|
||||
const dist = Math.hypot(vx * dt, vy * dt);
|
||||
const n = Math.max(1, Math.ceil(dist / 5));
|
||||
let hit = false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x += (vx * dt) / n; y += (vy * dt) / n;
|
||||
if (solid(x, y) || fluid(x, y)) { hit = true; break; }
|
||||
}
|
||||
const last = pts[pts.length - 1];
|
||||
if (!last || Math.abs(x - last[0]) + Math.abs(y - last[1]) > 16) pts.push([Math.round(x), Math.round(y)]);
|
||||
if (hit || pts.length > 90) break;
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// API для node (require) и для бандлера (общая область IIFE — без export).
|
||||
const __api = { STILE, SGRAV, SMOVE, SJUMP, WORM_W, WORM_H, WEAPONS, TURN_TIME, RETREAT_TIME, MAX_WIND, Sim, previewShot, solveShot };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = __api;
|
||||
File diff suppressed because it is too large
Load Diff
30
style.css
30
style.css
|
|
@ -2,17 +2,17 @@
|
|||
html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#111; font-family: system-ui, sans-serif; user-select:none; -webkit-user-select:none; touch-action:none; }
|
||||
#game { position:absolute; top:0; left:0; right:0; bottom:0; background:#87CEEB; overflow:hidden; }
|
||||
canvas { display:block; width:100%; height:100%; image-rendering:pixelated; }
|
||||
#controls { position:absolute; left:0; right:0; bottom:0; height:140px; background:#222; border-top:4px solid #444; z-index:100; display:none; }
|
||||
#controls { position:absolute; left:0; right:0; bottom:0; height:190px; background:#222; border-top:4px solid #444; z-index:100; display:none; }
|
||||
|
||||
/* Стили для кнопок управления (.btn вместо .cbtn для совместимости с index.html) */
|
||||
.btn { position:absolute; top:50%; transform:translateY(-50%); width:74px; height:74px; border-radius:14px;
|
||||
.btn { position:absolute; width:74px; height:74px; border-radius:14px;
|
||||
background:#333; border:3px solid #555; color:#fff; font-size:32px; display:flex; align-items:center; justify-content:center;
|
||||
box-shadow:0 7px 0 #111; pointer-events:auto; z-index:101; }
|
||||
.btn:active { transform:translateY(-46%); box-shadow:0 3px 0 #111; background:#444; }
|
||||
#left { left:18px; }
|
||||
#right { left:102px; }
|
||||
#down { left:186px; background:#2980b9; border-color:#3498db; }
|
||||
#jump { right:18px; background:#d35400; border-color:#e67e22; }
|
||||
.btn:active { box-shadow:0 3px 0 #111; background:#444; }
|
||||
#left { left:18px; top:14px; }
|
||||
#right { left:102px; top:14px; }
|
||||
#down { left:60px; top:100px; background:#2980b9; border-color:#3498db; }
|
||||
#jump { right:18px; top:14px; background:#d35400; border-color:#e67e22; }
|
||||
|
||||
/* Скрываем панель на десктопе (широкие экраны) */
|
||||
@media (min-width: 769px) {
|
||||
|
|
@ -36,11 +36,10 @@ canvas { display:block; width:100%; height:100%; image-rendering:pixelated; }
|
|||
font-size:24px; cursor:pointer; pointer-events:auto; box-shadow:0 4px 0 rgba(0,0,0,0.5); }
|
||||
.rbtn:active { transform: translateY(4px); box-shadow:none; }
|
||||
#modeBtn { top:10px; background:#f39c12; }
|
||||
#saveBtn { top:10px; right:70px !important; background:#27ae60; }
|
||||
#resetBtn { top:10px; right:130px !important; background:#e74c3c; }
|
||||
#mapToggle { top:10px; right:190px !important; background:#1abc9c; }
|
||||
#mapToggle { top:10px; right:70px !important; background:#1abc9c; }
|
||||
#craftBtn { top:74px; right:10px !important; background:#9b59b6; }
|
||||
#invToggle { top:74px; right:70px !important; background:#3498db; }
|
||||
#menuBtn { top:74px; right:130px !important; background:#555; }
|
||||
#chatToggle { display: none !important; }
|
||||
#chatPanel { display: none !important; }
|
||||
|
||||
|
|
@ -104,3 +103,14 @@ body.touch-device #hotbar {
|
|||
.custom-modal-box .btn-yes { background:#e74c3c; }
|
||||
.custom-modal-box .btn-no { background:#555; }
|
||||
.custom-modal-box .btn-ok { background:#2ecc71; }
|
||||
|
||||
/* Menu dropdown ⋯ */
|
||||
.menu-dropdown { position:absolute; top:120px; right:58px; background:rgba(20,20,30,0.95); border:2px solid rgba(255,255,255,0.7); border-radius:10px; display:none; flex-direction:column; min-width:140px; z-index:300; pointer-events:auto; overflow:hidden; }
|
||||
.menu-dropdown.open { display:flex; }
|
||||
.menu-item { padding:10px 14px; color:#fff; font-size:14px; font-weight:700; cursor:pointer; border-bottom:1px solid rgba(255,255,255,0.1); }
|
||||
.menu-item:last-child { border-bottom:none; }
|
||||
.menu-item:hover { background:rgba(255,255,255,0.12); }
|
||||
.menu-item:active { background:rgba(255,255,255,0.2); }
|
||||
|
||||
/* Market panel */
|
||||
#marketPanel { display:none; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:360px; max-width:90%; background:rgba(10,10,12,0.95); border:2px solid rgba(255,255,255,0.85); border-radius:14px; pointer-events:auto; padding:12px; z-index:200; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
Дробовик: Wikimedia Commons — «Dragout - War sounds (cc0) (freesound).ogg», CC0.
|
||||
УЗИ: нарезано из того же CC0-файла (5 ударов пика через 55 мс).
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,169 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>Гречка: Червяки</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #87CEEB; font-family: sans-serif; }
|
||||
#game { position: fixed; inset: 0; }
|
||||
#c { width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
#hud { position: fixed; top: 0; left: 0; right: 64px; padding: 6px 8px; color: #fff; font-size: 14px;
|
||||
text-shadow: 0 1px 3px #000; pointer-events: none; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
#hudWind { font-family: monospace; font-weight: bold; letter-spacing: 1px;
|
||||
background: rgba(0,0,0,0.45); border-radius: 6px; padding: 1px 7px; }
|
||||
/* оружие справа сверху */
|
||||
#wOpen { position: fixed; top: 8px; right: 8px; width: 52px; height: 52px; border-radius: 10px;
|
||||
background: rgba(0,0,0,0.55); border: 2px solid #f1c40f; display: flex; align-items: center; justify-content: center; z-index: 5; }
|
||||
#wOpen canvas { width: 40px; height: 40px; }
|
||||
#wPop { position: fixed; top: 68px; right: 8px; width: 248px; max-height: 60vh; overflow-y: auto;
|
||||
background: rgba(20,20,20,0.92); border-radius: 12px; padding: 8px; display: none; z-index: 6; }
|
||||
#wPop.on { display: block; }
|
||||
.wrow { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 8px; color: #fff; font-size: 14px; }
|
||||
.wrow.sel { background: #3d484b; outline: 2px solid #f1c40f; }
|
||||
.wrow.empty { opacity: 0.35; }
|
||||
.wrow canvas { width: 36px; height: 36px; background: #111; border-radius: 6px; }
|
||||
.wrow .ammo { margin-left: auto; color: #f1c40f; }
|
||||
/* зум */
|
||||
#zoom { position: fixed; right: 8px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 5; }
|
||||
.zbtn { width: 44px; height: 44px; border-radius: 50%; border: none; font-size: 22px; background: rgba(0,0,0,0.5); color: #fff; }
|
||||
/* движение как в оригинале */
|
||||
#controls { position: fixed; left: 8px; bottom: 12px; display: none; gap: 10px; z-index: 5; }
|
||||
@media (pointer: coarse) { #controls { display: flex; } }
|
||||
.btn { width: 64px; height: 64px; border-radius: 50%; background: rgba(0,0,0,0.5); color: #fff;
|
||||
font-size: 28px; display: flex; align-items: center; justify-content: center; user-select: none; -webkit-user-select: none; }
|
||||
.btn:active { background: rgba(241,196,15,0.6); }
|
||||
/* джойстик прицела */
|
||||
#stick { position: fixed; width: 120px; height: 120px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.7);
|
||||
background: rgba(0,0,0,0.25); display: none; z-index: 5; pointer-events: none; }
|
||||
#stickKnob { position: absolute; width: 52px; height: 52px; border-radius: 50%; background: rgba(241,196,15,0.8);
|
||||
left: 34px; top: 34px; }
|
||||
#banner, #modeOverlay { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.6); z-index: 10; }
|
||||
/* правая колонка: прыжок, над ним огонь */
|
||||
#jumpBtn { position: fixed; right: 12px; bottom: 12px; width: 84px; height: 64px; border-radius: 14px;
|
||||
border: 3px solid #00a1ff; background: rgba(0,0,0,0.5); color: #fff; font-size: 26px;
|
||||
display: block; z-index: 5; user-select: none; -webkit-user-select: none; }
|
||||
#jumpBtn:active { background: rgba(0,161,255,0.6); }
|
||||
/* кнопка огня */
|
||||
#fireBtn { position: fixed; right: 12px; bottom: 84px; width: 84px; height: 84px; border-radius: 50%;
|
||||
border: 3px solid #f1c40f; background: rgba(192,57,43,0.85); color: #fff; font-size: 15px; font-weight: bold;
|
||||
display: block; z-index: 5; user-select: none; -webkit-user-select: none; }
|
||||
#fireBtn:active { background: rgba(241,196,15,0.9); }
|
||||
#fuseBtn { position: fixed; right: 12px; bottom: 176px; min-width: 84px; height: 40px; border-radius: 10px;
|
||||
border: 2px solid #00a1ff; background: rgba(0,0,0,0.55); color: #fff; font-size: 15px; z-index: 5; display: none; }
|
||||
#hint { position: fixed; top: 66px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 14px;
|
||||
background: rgba(0,0,0,0.55); border-radius: 10px; padding: 6px 12px; pointer-events: none; z-index: 4;
|
||||
white-space: nowrap; max-width: 94vw; overflow: hidden; text-overflow: ellipsis; }
|
||||
#modeOverlay { display: flex; }
|
||||
#lobby, #invitePop, #pickPop { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.6); z-index: 11; }
|
||||
#lobby .box input { font-size: 18px; padding: 10px; border-radius: 8px; border: 1px solid #ccc; margin: 6px; width: 200px; text-align: center; }
|
||||
#roomCode { font-size: 26px; font-weight: bold; letter-spacing: 4px; margin: 8px; }
|
||||
#modeRow { display: flex; gap: 8px; justify-content: center; margin: 8px 0; }
|
||||
#modeRow .big, #dnRow .big { background: #95a5a6; opacity: 0.7; }
|
||||
#modeRow .big.on { background: #27ae60; opacity: 1; }
|
||||
#dnRow { display: flex; gap: 8px; justify-content: center; margin: 8px 0; }
|
||||
#dnRow .big.on { background: #2980b9; opacity: 1; }
|
||||
#topTable { margin-top: 10px; font-size: 14px; color: #333; }
|
||||
#topTable table { margin: 4px auto; border-collapse: collapse; }
|
||||
#topTable td { padding: 1px 8px; text-align: left; }
|
||||
#netStatus { font-size: 14px; margin: 4px; color: #e67e22; }
|
||||
#netStatus.ok { color: #27ae60; }
|
||||
#netStatus.bad { color: #c0392b; }
|
||||
.ltext { font-size: 13px; color: #555; margin: 6px 0 2px; }
|
||||
#specBadge { position: fixed; bottom: 8px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.6); color: #ffe14d; font-size: 13px; border-radius: 8px;
|
||||
padding: 4px 12px; z-index: 5; pointer-events: none; }
|
||||
#rosterList { max-height: 38vh; overflow-y: auto; }
|
||||
#micBtn { position: fixed; right: 12px; top: 116px; width: 52px; height: 52px; border-radius: 50%;
|
||||
font-size: 24px; background: rgba(0,0,0,0.55); border: 2px solid #fff; z-index: 5; }
|
||||
#micBtn.on { background: rgba(46,204,113,0.9); border-color: #2ecc71; }
|
||||
#micBtn.err { background: rgba(231,76,60,0.9); }
|
||||
#earBtn { position: fixed; right: 12px; top: 116px; width: 52px; height: 52px; border-radius: 50%;
|
||||
font-size: 24px; background: rgba(0,0,0,0.55); border: 2px solid #fff; z-index: 5; }
|
||||
#earBtn.on { background: rgba(52,152,219,0.9); border-color: #3498db; }
|
||||
#speakInd { position: fixed; right: 72px; top: 128px; background: rgba(0,0,0,0.7); color: #2ecc71;
|
||||
padding: 4px 10px; border-radius: 8px; font-size: 13px; z-index: 5; pointer-events: none; }
|
||||
#roster { position: fixed; left: 8px; top: 64px; z-index: 4; background: rgba(0,0,0,0.55); color: #fff;
|
||||
border-radius: 10px; padding: 8px 10px; font-size: 13px; display: none; max-width: 44vw; }
|
||||
#roster button { margin-left: 6px; font-size: 14px; }
|
||||
.box { background: #fff; border-radius: 12px; padding: 24px 32px; text-align: center; max-width: 86vw; }
|
||||
.box .b-text { font-size: 22px; margin-bottom: 16px; }
|
||||
.big { font-size: 18px; padding: 12px 24px; border-radius: 10px; border: none; background: #27ae60; color: #fff; margin: 6px; }
|
||||
.big.blue { background: #2980b9; }
|
||||
#help { position: fixed; left: 8px; top: 44px; color: #fff; font-size: 12px; text-shadow: 0 1px 3px #000;
|
||||
pointer-events: none; opacity: 0.85; }
|
||||
@media (pointer: coarse) { #help { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"><canvas id="c"></canvas></div>
|
||||
<div id="hud"><span id="hudTeams"></span><span id="hudTurn"></span><span id="hudWind"></span></div>
|
||||
<div id="specBadge" style="display:none">👀 Ты наблюдатель — болеешь с балкона (⬆️ — прыгать)</div>
|
||||
<div id="help" style="display:none"></div>
|
||||
<div id="wOpen" title="Оружие"></div>
|
||||
<div id="wPop"></div>
|
||||
<div id="zoom"><button class="zbtn" id="zIn">+</button><button class="zbtn" id="zOut">−</button></div>
|
||||
<div id="controls">
|
||||
<div id="left" class="btn">⬅️</div>
|
||||
<div id="right" class="btn">➡️</div>
|
||||
</div>
|
||||
<div id="stick"><div id="stickKnob"></div></div>
|
||||
<div id="hint"></div>
|
||||
<button id="fuseBtn">⏱3с</button>
|
||||
<button id="jumpBtn">⬆️</button>
|
||||
<button id="fireBtn">ОГОНЬ</button>
|
||||
<button id="micBtn" style="display:none" title="Голосовой чат (выкл)">🎤</button>
|
||||
<button id="earBtn" style="display:none" title="Слушать бойцов (выкл)">🔊</button>
|
||||
<div id="speakInd" style="display:none"></div>
|
||||
<div id="modeOverlay"><div class="box">
|
||||
<div class="b-text">🌾 Гречка: Червяки</div>
|
||||
<div>Две горы. Между ними — вода.<br>5 на 5. Побеждает команда.</div>
|
||||
<div style="margin:10px 0" id="dnRow">
|
||||
<button class="big" id="dDay" style="background:#f39c12">☀️ День</button>
|
||||
<button class="big" id="dNight" style="background:#34495e">🌙 Ночь</button>
|
||||
</div><br>
|
||||
<button class="big blue" id="mAi">🤖 С компом</button>
|
||||
<button class="big" id="mHot">🎮 Вдвоём</button><br>
|
||||
<button class="big" id="mNet" style="background:#8e44ad">🌐 По сети</button>
|
||||
</div></div>
|
||||
<div id="lobby"><div class="box">
|
||||
<div class="b-text">🌐 Игра по сети</div>
|
||||
<div id="lobbyForm">
|
||||
<div id="netStatus">Подключение…</div>
|
||||
<input id="nick" maxlength="20" placeholder="Твоё имя"><br>
|
||||
<div class="ltext">Новая игра — код выдадим сами:</div>
|
||||
<button class="big" id="bCreate">Создать игру</button><br>
|
||||
<div class="ltext">— или войти по коду друга —</div>
|
||||
<input id="codeIn" maxlength="5" inputmode="numeric" placeholder="Код: 5 цифр"><br>
|
||||
<button class="big blue" id="bJoin">Присоединиться</button>
|
||||
<div id="topTable">🏆 Топ-10: загрузка…</div>
|
||||
<div id="roomsTable" style="margin-top:8px">👀 Сейчас играют: загрузка…</div>
|
||||
</div>
|
||||
<div id="lobbyWait" style="display:none">
|
||||
<div id="roomCode">Код: …</div>
|
||||
<div>Друзья вводят код и появляются в твоём мире.<br>Третий и дальше — зрители на балкончиках.<br>Второй игрок зашёл — бой начинается сам.</div>
|
||||
<div id="modeRow"><button class="big on" id="bModeTurns">🚶 По очереди</button><button class="big" id="bModeSim">⚡ Одновременно</button></div>
|
||||
<div id="dnRow"><button class="big on" id="bDay">☀️ День</button><button class="big" id="bNight">🌙 Ночь</button></div>
|
||||
<div id="lobbyNote" style="display:none">Ждём создателя… Бой начнётся сам, как только оба в комнате.</div>
|
||||
<button class="big blue" id="bCopyLink">📋 Ссылка на бой</button><br>
|
||||
</div>
|
||||
</div></div>
|
||||
<div id="roster"><div class="b-text">Комната</div><div id="rosterList"></div></div>
|
||||
<div id="invitePop"><div class="box">
|
||||
<div class="b-text" id="inviteText">Зовут играть!</div>
|
||||
<button class="big" id="bAccept">Играю!</button>
|
||||
<button class="big" id="bDecline" style="background:#7f8c8d">Откажусь</button>
|
||||
</div></div>
|
||||
<div id="pickPop"><div class="box">
|
||||
<div class="b-text">🏆 Победа! С кем дальше?</div>
|
||||
<div id="pickList"></div>
|
||||
<button class="big blue" id="bSame">Ещё раз с тем же</button>
|
||||
</div></div>
|
||||
<div id="banner"><div class="box"><div class="b-text"></div><button class="big" id="again">Ещё бой</button></div></div>
|
||||
|
||||
<script src="vendor/socket.io.min.js"></script>
|
||||
<script src="worms.js?v=62"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,55 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>Гречка: Червяки — хотсит</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #87CEEB; font-family: sans-serif; }
|
||||
#game { position: fixed; inset: 0; }
|
||||
#c { width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
#hud { position: fixed; top: 0; left: 0; right: 0; padding: 6px 8px; color: #fff; font-size: 14px;
|
||||
text-shadow: 0 1px 3px #000; pointer-events: none; display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
#wbar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 4px; padding: 6px;
|
||||
overflow-x: auto; background: rgba(0,0,0,0.45); }
|
||||
.wbtn { flex: 1 0 auto; min-width: 86px; padding: 10px 6px; font-size: 13px; border-radius: 8px;
|
||||
border: 2px solid transparent; background: #2d3436; color: #fff; }
|
||||
.wbtn.sel { border-color: #f1c40f; background: #3d484b; }
|
||||
.wbtn.empty { opacity: 0.35; }
|
||||
#touch { position: fixed; right: 8px; bottom: 64px; display: none; flex-direction: column; gap: 8px; }
|
||||
@media (pointer: coarse) { #touch { display: flex; } #wbar { bottom: 0; } }
|
||||
.tbtn { width: 60px; height: 60px; border-radius: 50%; border: none; font-size: 24px;
|
||||
background: rgba(0,0,0,0.5); color: #fff; }
|
||||
#tAim { width: 60px; writing-mode: vertical-lr; direction: rtl; height: 140px; }
|
||||
#move { position: fixed; left: 8px; bottom: 64px; display: none; gap: 8px; }
|
||||
@media (pointer: coarse) { #move { display: flex; } }
|
||||
#banner { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.6); z-index: 10; }
|
||||
#banner .box { background: #fff; border-radius: 12px; padding: 24px 32px; text-align: center; }
|
||||
#banner .b-text { font-size: 22px; margin-bottom: 16px; }
|
||||
#again { font-size: 18px; padding: 10px 24px; border-radius: 8px; border: none; background: #27ae60; color: #fff; }
|
||||
#help { position: fixed; left: 8px; top: 44px; color: #fff; font-size: 12px; text-shadow: 0 1px 3px #000;
|
||||
pointer-events: none; opacity: 0.85; }
|
||||
@media (pointer: coarse) { #help { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"><canvas id="c"></canvas></div>
|
||||
<div id="hud"><span id="hudTeams"></span><span id="hudTurn"></span><span id="hudWind"></span><span id="hudWeapon"></span></div>
|
||||
<div id="help">A/D — идти · W/S — прицел · Пробел (держать) — сила/выстрел · Shift — прыжок · Tab — оружие · F — таймер</div>
|
||||
<div id="move">
|
||||
<button class="tbtn" id="tLeft">◀</button>
|
||||
<button class="tbtn" id="tRight">▶</button>
|
||||
<button class="tbtn" id="tJump">⤒</button>
|
||||
</div>
|
||||
<div id="touch">
|
||||
<input type="range" id="tAim" min="0" max="100" value="30">
|
||||
<button class="tbtn" id="tFuse">⏱</button>
|
||||
<button class="tbtn" id="tWeapon">🌀</button>
|
||||
<button class="tbtn" id="tFire">🔥</button>
|
||||
</div>
|
||||
<div id="wbar"></div>
|
||||
<div id="banner"><div class="box"><div class="b-text"></div><button id="again">Ещё бой</button></div></div>
|
||||
<script src="worms.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue