Compare commits

..

13 Commits

11 changed files with 753 additions and 261 deletions

View File

@ -4,6 +4,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html COPY index.html /usr/share/nginx/html/index.html
COPY style.css /usr/share/nginx/html/style.css COPY style.css /usr/share/nginx/html/style.css
COPY game.js /usr/share/nginx/html/game.js COPY game.js /usr/share/nginx/html/game.js
COPY src/ /usr/share/nginx/html/src/
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

128
build.js
View File

@ -1,128 +0,0 @@
#!/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 = '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);

590
game.js
View File

@ -1,5 +1,48 @@
(() => { (() => {
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ==================== // ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
// === Custom modal functions ===
function customAlert(msg) {
const overlay = document.createElement("div");
overlay.className = "custom-modal-overlay";
const box = document.createElement("div");
box.className = "custom-modal-box";
const text = document.createElement("div");
text.textContent = msg;
text.style.marginBottom = "16px";
const btn = document.createElement("button");
btn.className = "btn-ok";
btn.textContent = "OK";
btn.onclick = () => overlay.remove();
box.appendChild(text);
box.appendChild(btn);
overlay.appendChild(box);
document.querySelector("#game").appendChild(overlay);
}
function customConfirm(msg, onYes) {
const overlay = document.createElement("div");
overlay.className = "custom-modal-overlay";
const box = document.createElement("div");
box.className = "custom-modal-box";
const text = document.createElement("div");
text.textContent = msg;
text.style.marginBottom = "16px";
const btns = document.createElement("div");
btns.className = "modal-btns";
const yesBtn = document.createElement("button");
yesBtn.className = "btn-yes";
yesBtn.textContent = "Да";
yesBtn.onclick = () => { overlay.remove(); onYes(); };
const noBtn = document.createElement("button");
noBtn.className = "btn-no";
noBtn.textContent = "Отмена";
noBtn.onclick = () => overlay.remove();
btns.appendChild(yesBtn);
btns.appendChild(noBtn);
box.appendChild(text);
box.appendChild(btns);
overlay.appendChild(box);
document.querySelector("#game").appendChild(overlay);
}
// Возможность переопределить сервер через query string // Возможность переопределить сервер через query string
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru'; const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
@ -84,7 +127,45 @@
let socket = null; let socket = null;
let isMultiplayer = false; // Флаг для мультиплеерного режима let isMultiplayer = false; // Флаг для мультиплеерного режима
const otherPlayers = new Map(); // socket_id -> {x, y, color} const otherPlayers = new Map(); // socket_id -> {x, y, color}
const serverMobs = new Map(); // id -> mob (server-authoritative in MP) const serverMobs = new Map(); // id -> mob (server-spawned, client-authoritative physics)
// Helper to get all mobs (local + server-spawned in MP)
function getAllMobs() {
return isMultiplayer ? mobs.concat(Array.from(serverMobs.values())) : mobs;
}
// Create a client-side mob object from server spawn data with correct properties matching client constructors
function createMobFromServer(data) {
const kindProps = {
zombie: { w: 34, h: 50, hp: 4, speed: 80 + Math.random() * 40, hostile: true, fuse: 0, shootCooldown: 2 },
creeper: { w: 34, h: 50, hp: 4, speed: 60 + Math.random() * 30, hostile: true, fuse: 3.2, shootCooldown: 2 },
skeleton: { w: 34, h: 50, hp: 4, speed: 70 + Math.random() * 30, hostile: true, fuse: 0, shootCooldown: 0 },
pig: { w: 34, h: 24, hp: 2, speed: 40, hostile: false, fuse: 0, shootCooldown: 2 },
chicken: { w: 26, h: 22, hp: 1, speed: 55, hostile: false, fuse: 0, shootCooldown: 2 }
};
const props = kindProps[data.kind] || kindProps['pig']; // fallback
return {
id: data.id,
kind: data.kind,
x: data.x,
y: data.y,
w: props.w,
h: props.h,
hp: data.hp || props.hp,
maxHp: data.maxHp || data.hp || props.hp,
speed: props.speed,
hostile: props.hostile,
vx: 0,
vy: 0,
grounded: false,
inWater: false,
aiT: 0,
dir: data.dir || 1,
dead: false,
fuse: props.fuse,
shootCooldown: props.shootCooldown
};
}
let mySocketId = null; let mySocketId = null;
// Throttle для отправки позиции (10-20 раз в секунду) // Throttle для отправки позиции (10-20 раз в секунду)
@ -109,6 +190,13 @@
// Показываем в UI // Показываем в UI
worldIdEl.textContent = worldId; worldIdEl.textContent = worldId;
// XP/Level display
const lvXpNext = xpForLevel(player.level + 1);
const lvXpCur = xpForLevel(player.level);
const xpInLevel = player.xp - lvXpCur;
const xpNeeded = lvXpNext - lvXpCur;
document.getElementById('xplevel').textContent = player.level;
document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
multiplayerStatus.style.display = 'block'; multiplayerStatus.style.display = 'block';
}); });
@ -219,11 +307,11 @@
// Обновляем счётчик игроков // Обновляем счётчик игроков
playerCountEl.textContent = data.players.length; playerCountEl.textContent = data.players.length;
} }
// Server mobs // Server mobs — client-authoritative: create with full client-side properties
if (data.mobs && Array.isArray(data.mobs)) { if (data.mobs && Array.isArray(data.mobs)) {
serverMobs.clear(); serverMobs.clear();
for (const m of data.mobs) { for (const m of data.mobs) {
const sm = { ...m, maxHp: m.maxHp || m.hp, vx: m.vx||0, vy: m.vy||0, grounded:false, inWater:false, aiT:0, dir:m.dir||1, dead:false, fuse:m.fuse||0, shootCooldown:2, speed: m.speed || 80 }; const sm = createMobFromServer(m);
serverMobs.set(m.id, sm); serverMobs.set(m.id, sm);
} }
} }
@ -277,14 +365,16 @@
// === MOB SYNC (multiplayer) === // === MOB SYNC (multiplayer) ===
socket.on('mob_spawned', (data) => { socket.on('mob_spawned', (data) => {
const sm = { ...data, maxHp: data.maxHp || data.hp, vx: data.vx||0, vy: data.vy||0, grounded:false, inWater:false, aiT:0, dir:data.dir||1, dead:false, fuse:data.fuse||0, shootCooldown:2, speed: data.speed || 80 }; const sm = createMobFromServer(data);
serverMobs.set(data.id, sm); serverMobs.set(data.id, sm);
}); });
socket.on('mob_positions', (arr) => { socket.on('mob_positions', (arr) => {
// Client-authoritative: ignore server positions, mobAI handles physics locally.
// Only update HP/fuse/direction for consistency (e.g. if another client hurt the mob).
for (const u of arr) { for (const u of arr) {
const sm = serverMobs.get(u.id); const sm = serverMobs.get(u.id);
if (sm) { sm.x=u.x; sm.y=u.y; sm.vx=u.vx; sm.vy=u.vy; sm.dir=u.dir; sm.hp=u.hp; sm.fuse=u.fuse||0; } if (sm) { sm.hp = u.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; }
} }
}); });
@ -295,11 +385,8 @@
if (sm && data.killer === mySocketId) { if (sm && data.killer === mySocketId) {
// Give loot to the killer // Give loot to the killer
if (sm.kind === 'chicken') playSound('hurt_chicken'); if (sm.kind === 'chicken') playSound('hurt_chicken');
inv.meat += (sm.kind==='chicken' ? 1 : 2); spawnDrops(sm.x, sm.y, sm.kind);
if (sm.kind === 'skeleton') { grantXP(getMobXP(sm.kind));
inv.arrow += 2 + Math.floor(Math.random()*3);
if (Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
}
rebuildHotbar(); rebuildHotbar();
} }
serverMobs.delete(data.id); serverMobs.delete(data.id);
@ -561,8 +648,8 @@
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
brick: { n:'Кирпич', c:'#c0392b', solid:true }, brick: { n:'Кирпич', c:'#c0392b', solid:true },
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 }, campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 },
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 }, torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 },
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true }, bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true }, flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true }, bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
@ -574,6 +661,10 @@
meat: { n:'Сырое мясо', icon:'🥩', food:15 }, meat: { n:'Сырое мясо', icon:'🥩', food:15 },
cooked: { n:'Жареное мясо', icon:'🍖', food:45 }, cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
arrow: { n:'Стрела', icon:'➡️', stack:64 }, arrow: { n:'Стрела', icon:'➡️', stack:64 },
chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
feather: { n:'Перо', icon:'🪶', stack:64 },
bone: { n:'Кость', icon:'🦴', stack:64 },
gunpowder: { n:'Порох', icon:'💥', stack:64 }
}; };
// Seed мира для детерминированной генерации // Seed мира для детерминированной генерации
@ -1071,7 +1162,7 @@
} }
// Мобы — красные (враждебные) / зелёные (животные) // Мобы — красные (враждебные) / зелёные (животные)
const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : mobs; const allMobsForMap = getAllMobs();
for (const m of allMobsForMap) { for (const m of allMobsForMap) {
const dx = Math.floor(m.x / TILE) - startGX; const dx = Math.floor(m.x / TILE) - startGX;
const dy = Math.floor(m.y / TILE) - startGY; const dy = Math.floor(m.y / TILE) - startGY;
@ -1207,6 +1298,8 @@
let audioCtx = null; let audioCtx = null;
let voiceProcessor = null; let voiceProcessor = null;
let voiceActive = false; let voiceActive = false;
let voiceMode = 'near'; // 'near' or 'world'
let voiceDebugCount = 0;
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru'; const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
// Кнопка микрофона // Кнопка микрофона
@ -1216,6 +1309,29 @@
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;'; 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); 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'); 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;'; 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;';
@ -1227,6 +1343,7 @@
if (voiceActive) { if (voiceActive) {
// Выключить // Выключить
voiceActive = false; voiceActive = false;
ringReady = 0; ringRead = ringWrite; voicePlayActive = false;
voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>'; voiceBtn.innerHTML = '🎤<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:36px;color:#e74c3c;font-weight:bold;">/</span>';
voiceBtn.style.background = '#555'; voiceBtn.style.background = '#555';
if (voiceStream) { if (voiceStream) {
@ -1241,15 +1358,24 @@
// Включить // Включить
try { try {
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } }); voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
audioCtx = new AudioContext({ sampleRate: 24000 }); audioCtx = new AudioContext({ sampleRate: 24000 });
if (audioCtx.state === 'suspended') await audioCtx.resume();
console.log('[voice] AudioContext state:', audioCtx.state, 'sampleRate:', audioCtx.sampleRate);
const source = audioCtx.createMediaStreamSource(voiceStream); const source = audioCtx.createMediaStreamSource(voiceStream);
voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1); voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
console.log('[voice] ScriptProcessor created, bufferSize=2048');
voiceProcessor.onaudioprocess = (e) => { voiceProcessor.onaudioprocess = (e) => {
if (!voiceActive || !voiceSocket || !voiceSocket.connected) return; if (!voiceActive) return;
voiceDebugCount++;
if (voiceDebugCount <= 5) {
const pcm = e.inputBuffer.getChannelData(0);
console.log('[voice] onaudioprocess #' + voiceDebugCount, 'samples:', pcm.length, 'max:', Math.max(...pcm.map(Math.abs)).toFixed(4), 'socket:', !!voiceSocket, 'connected:', voiceSocket?.connected);
}
if (!voiceSocket || !voiceSocket.connected) return;
const pcm = e.inputBuffer.getChannelData(0); const pcm = e.inputBuffer.getChannelData(0);
// Конвертируем float32 → int16 для экономии трафика
const int16 = new Int16Array(pcm.length); const int16 = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i++) { for (let i = 0; i < pcm.length; i++) {
const s = Math.max(-1, Math.min(1, pcm[i])); const s = Math.max(-1, Math.min(1, pcm[i]));
@ -1258,54 +1384,116 @@
voiceSocket.emit('voice_data', int16.buffer); voiceSocket.emit('voice_data', int16.buffer);
}; };
source.connect(voiceProcessor); // Chain: source → processor → gain(0) → destination
// ScriptProcessor MUST connect to destination to fire onaudioprocess events // ScriptProcessor MUST reach destination to fire onaudioprocess
// Use a zero-gain node to silence own playback while keeping the processor active
const silentGain = audioCtx.createGain(); const silentGain = audioCtx.createGain();
silentGain.gain.value = 0; // mute — don't hear ourselves silentGain.gain.value = 0;
source.connect(voiceProcessor);
voiceProcessor.connect(silentGain); voiceProcessor.connect(silentGain);
silentGain.connect(audioCtx.destination); silentGain.connect(audioCtx.destination);
console.log('[voice] Audio chain: source → processor → silentGain(0) → destination');
// Подключаемся к голосовому серверу // Подключаемся к голосовому серверу
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] }); voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
voiceSocket.on('connect', () => { voiceSocket.on('connect', () => {
voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' }); console.log('[voice] Socket connected, id:', voiceSocket.id);
voiceSocket.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок', mode: voiceMode });
});
voiceSocket.on('connect_error', (err) => {
console.error('[voice] Socket connect error:', err.message);
}); });
// === Ring Buffer + ScriptProcessor приём голоса ===
// Единый непрерывный поток вместо отдельных BufferSource на чанк
const RING_SIZE = 24000 * 3; // 3 секунды ring buffer
const ringBuf = new Float32Array(RING_SIZE);
let ringWrite = 0; // позиция записи
let ringRead = 0; // позиция чтения
let ringReady = 0; // сколько сэмплов готово
let voicePlayActive = false;
const JBUF_TARGET = 4800; // целевой jitter buffer: 200мс при 24kHz
let jbufFill = 0; // текущее заполнение
let lastVoiceFrom = ''; // кто говорит (для индикатора)
// Воспроизводящий ScriptProcessor — читает из ring buffer
const playProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
let lastSample = 0; // для плавного fade при underrun
playProcessor.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
if (ringReady < 1) {
// Плавный fade-out от последнего сэмпла к тишине
for (let i = 0; i < out.length; i++) {
lastSample *= 0.9;
out[i] = lastSample;
}
voicePlayActive = false;
return;
}
// Ждём накопления jitter buffer перед стартом
if (!voicePlayActive && ringReady >= JBUF_TARGET) {
voicePlayActive = true;
}
if (!voicePlayActive) {
// Плавно затихаем пока буфер копится
for (let i = 0; i < out.length; i++) {
lastSample *= 0.95;
out[i] = lastSample;
}
return;
}
// Читаем из ring buffer с плавным fade-in на старте
for (let i = 0; i < out.length; i++) {
if (ringReady > 0) {
out[i] = ringBuf[ringRead];
lastSample = out[i]; // запоминаем для fade-out
ringRead = (ringRead + 1) % RING_SIZE;
ringReady--;
} else {
// Underrun — плавно затихаем
lastSample *= 0.85;
out[i] = lastSample;
}
}
};
const playGain = audioCtx.createGain();
playGain.gain.value = 1.0;
playProcessor.connect(playGain).connect(audioCtx.destination);
voiceSocket.on('voice_in', (payload) => { voiceSocket.on('voice_in', (payload) => {
// Воспроизводим входящий голос // Пишем входящий голос в ring buffer
const { data, meta, volume } = payload; const { data, meta, volume } = payload;
if (!audioCtx || audioCtx.state === 'closed') return; if (!audioCtx || audioCtx.state === 'closed') return;
// Int16 → Float32
const int16 = new Int16Array(data); const int16 = new Int16Array(data);
const float32 = new Float32Array(int16.length); const vol = Math.min(1.4, (volume || 1) * 1.5);
for (let i = 0; i < int16.length; i++) { for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume; const sample = (int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF)) * vol;
ringBuf[ringWrite] = sample;
ringWrite = (ringWrite + 1) % RING_SIZE;
ringReady = Math.min(ringReady + 1, RING_SIZE);
} }
// Сброс jitter fill если пауза была
const buf = audioCtx.createBuffer(1, float32.length, 24000); jbufFill = ringReady;
buf.getChannelData(0).set(float32); lastVoiceFrom = meta.name || '???';
const src = audioCtx.createBufferSource();
src.buffer = buf;
const gain = audioCtx.createGain();
gain.gain.value = volume;
src.connect(gain).connect(audioCtx.destination);
src.start();
// Индикатор // Индикатор
speakingIndicator.style.display = 'block'; speakingIndicator.style.display = 'block';
speakingIndicator.textContent = `🔊 ${meta.name}`; speakingIndicator.textContent = '🔊 ' + lastVoiceFrom;
clearTimeout(speakingTimeout); clearTimeout(speakingTimeout);
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500); speakingTimeout = setTimeout(() => {
speakingIndicator.style.display = 'none';
voicePlayActive = false; // сброс при паузе
ringReady = 0; // очистить буфер
ringRead = ringWrite; // синхронизировать
}, 1500);
}); });
voiceActive = true; voiceActive = true;
voiceBtn.textContent = '🎤'; voiceBtn.textContent = '🎤';
voiceBtn.style.background = '#2ecc71'; voiceBtn.style.background = '#2ecc71';
console.log('[voice] Voice chat ACTIVE');
} catch(e) { } catch(e) {
console.error('Voice error:', e); console.error('[voice] Error:', e);
voiceBtn.style.background = '#e74c3c'; voiceBtn.style.background = '#e74c3c';
} }
}; };
@ -1349,11 +1537,16 @@
s.appendChild(c); s.appendChild(c);
s.onclick = () => { s.onclick = () => {
playSound('click'); // Звук клика по инвентарю playSound('click'); // Звук клика по инвентарю
selected=id; if(selected === id) {
// Обновляем список последних предметов // Повторный клик — снимаем выбор, возвращаем к первому блоку
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
recentItems.unshift(id); // Добавляем в начало } else {
recentItems = recentItems.slice(0, 5); // Оставляем только 5 selected = id;
// Обновляем список последних предметов
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
recentItems.unshift(id); // Добавляем в начало
recentItems = recentItems.slice(0, 5); // Оставляем только 5
}
rebuildHotbar(); rebuildHotbar();
}; };
@ -1423,11 +1616,14 @@
slot.onclick = () => { slot.onclick = () => {
playSound('click'); // Звук клика по инвентарю playSound('click'); // Звук клика по инвентарю
selected = id; if(selected === id) {
// Обновляем список последних предметов selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть } else {
recentItems.unshift(id); // Добавляем в начало selected = id;
recentItems = recentItems.slice(0, 5); // Оставляем только 5 recentItems = recentItems.filter(item => item !== id);
recentItems.unshift(id);
recentItems = recentItems.slice(0, 5);
}
rebuildHotbar(); rebuildHotbar();
renderInventory(); renderInventory();
}; };
@ -1543,12 +1739,14 @@
// Кнопка открытия инвентаря // Кнопка открытия инвентаря
document.getElementById('invToggle').onclick = () => { document.getElementById('invToggle').onclick = () => {
playSound('click'); // Звук клика по кнопке playSound('click'); // Звук клика по кнопке
inventoryOpen = true; inventoryOpen = !inventoryOpen;
inventoryPanel.style.display = 'block'; inventoryPanel.style.display = inventoryOpen ? 'block' : 'none';
renderInventory(); if(inventoryOpen) {
// Закрываем крафт если открыт инвентарь renderInventory();
craftOpen = false; // Закрываем крафт если открыт инвентарь
craftPanel.style.display = 'none'; craftOpen = false;
craftPanel.style.display = 'none';
}
}; };
document.getElementById('inventoryClose').onclick = () => { document.getElementById('inventoryClose').onclick = () => {
@ -1562,13 +1760,13 @@
saveBtn.onclick = () => { saveBtn.onclick = () => {
playSound('click'); playSound('click');
saveGame(); saveGame();
alert('Игра сохранена!'); customAlert('Игра сохранена!');
}; };
// Кнопка сброса игры (удаление сохранения и создание нового мира) // Кнопка сброса игры (удаление сохранения и создание нового мира)
const resetBtn = document.getElementById('resetBtn'); const resetBtn = document.getElementById('resetBtn');
resetBtn.onclick = () => { resetBtn.onclick = () => {
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) { customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
playSound('click'); playSound('click');
// Удаляем сохранение из localStorage // Удаляем сохранение из localStorage
@ -1602,7 +1800,7 @@
// Перезагружаем страницу // Перезагружаем страницу
location.reload(); location.reload();
} });
}; };
// Показываем кнопку сохранения только если играем одни // Показываем кнопку сохранения только если играем одни
@ -1696,12 +1894,131 @@
sleeping: false, sleeping: false,
inBoat: false, inBoat: false,
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня) armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
equippedArmor: null // Тип надетой брони equippedArmor: null, // Тип надетой брони
xp: 0,
level: 1
}; };
// Сохраняем начальную позицию для возрождения // Сохраняем начальную позицию для возрождения
const spawnPoint = { x: 6*TILE, y: 0*TILE }; const spawnPoint = { x: 6*TILE, y: 0*TILE };
// Система дропов с мобов
const drops = []; // {x, y, vy, item, qty, age}
let levelUpPopup = null; // {text, timer}
function xpForLevel(lv) {
if (lv <= 1) return 0;
const thresholds = [0, 50, 150, 300, 500, 800, 1200, 1700, 2300, 3000];
if (lv - 1 < thresholds.length) return thresholds[lv - 1];
return Math.floor(3000 + (lv - 10) * (lv - 10) * 50 + (lv - 10) * 200);
}
function getMobLoot(kind) {
const table = {
chicken: [{item:'chicken_meat',min:1,max:2,chance:1},{item:'feather',min:0,max:1,chance:0.5}],
pig: [{item:'meat',min:1,max:2,chance:1}],
zombie: [{item:'meat',min:0,max:1,chance:0.5},{item:'iron_ingot',min:0,max:1,chance:0.3}],
skeleton: [{item:'arrow',min:2,max:4,chance:1},{item:'bone',min:0,max:2,chance:0.6},{item:'bow',min:1,max:1,chance:0.15}],
creeper: [{item:'gunpowder',min:1,max:2,chance:1}]
};
return table[kind] || [];
}
function getMobXP(kind) {
const xpTable = { chicken:3, pig:5, zombie:10, skeleton:12, creeper:15 };
return xpTable[kind] || 0;
}
function spawnDrops(mx, my, kind) {
const loot = getMobLoot(kind);
for (const entry of loot) {
if (Math.random() > entry.chance) continue;
const qty = entry.min + Math.floor(Math.random() * (entry.max - entry.min + 1));
if (qty <= 0) continue;
drops.push({
x: mx + (Math.random() - 0.5) * 20,
y: my + (Math.random() - 0.5) * 10,
vy: -1 - Math.random() * 2,
item: entry.item,
qty: qty,
age: 0
});
}
}
function grantXP(amount) {
player.xp += amount;
while (player.xp >= xpForLevel(player.level + 1)) {
player.level++;
levelUpPopup = { text: '⭐ Уровень ' + player.level + '!', timer: 180 };
}
}
function pickupDrops() {
for (let i = drops.length - 1; i >= 0; i--) {
const d = drops[i];
const dx = player.x + player.w/2 - d.x;
const dy = player.y + player.h/2 - d.y;
if (dx*dx + dy*dy < 30*30) {
if (!inv[d.item]) inv[d.item] = 0;
inv[d.item] += d.qty;
drops.splice(i, 1);
rebuildHotbar();
}
}
}
function drawDrops(ctx) {
for (let i = drops.length - 1; i >= 0; i--) {
const d = drops[i];
d.age++;
if (d.age > 3600) { drops.splice(i, 1); continue; } // 60 sec at 60fps
// Bounce animation
const bounce = Math.abs(Math.sin(d.age * 0.05)) * 6;
const dy = d.y - bounce;
const sx = d.x - camX;
const sy = dy - camY;
// Skip if off screen
if (sx < -40 || sx > W + 40 || sy < -40 || sy > H + 40) continue;
// Glow effect
ctx.save();
ctx.globalAlpha = 0.3;
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(sx, sy, 14, 0, Math.PI*2);
ctx.fill();
ctx.restore();
// Item icon
ctx.save();
ctx.font = '16px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const itemDef = ITEMS[d.item];
const icon = itemDef ? itemDef.icon : '🎁';
const label = d.qty > 1 ? icon + '×' + d.qty : icon;
ctx.fillText(label, sx, sy);
ctx.restore();
}
}
function drawLevelUpPopup(ctx) {
if (!levelUpPopup) return;
levelUpPopup.timer--;
if (levelUpPopup.timer <= 0) { levelUpPopup = null; return; }
const alpha = Math.min(1, levelUpPopup.timer / 60);
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 36px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.strokeText(levelUpPopup.text, W/2, H/2 - 60);
ctx.fillText(levelUpPopup.text, W/2, H/2 - 60);
ctx.restore();
}
// Система сохранения игры (localStorage + in-memory fallback) // Система сохранения игры (localStorage + in-memory fallback)
const SAVE_KEY = 'minegrechka_save'; const SAVE_KEY = 'minegrechka_save';
let db = null; // Оставляем для совместимости, но не используем let db = null; // Оставляем для совместимости, но не используем
@ -1730,7 +2047,9 @@
y: player.y, y: player.y,
hp: player.hp, hp: player.hp,
hunger: player.hunger, hunger: player.hunger,
o2: player.o2 o2: player.o2,
xp: player.xp,
level: player.level
}, },
inventory: inv, inventory: inv,
time: worldTime, time: worldTime,
@ -1828,6 +2147,8 @@
player.y = saveData.player.y; player.y = saveData.player.y;
player.hunger = saveData.player.hunger; player.hunger = saveData.player.hunger;
player.o2 = saveData.player.o2; player.o2 = saveData.player.o2;
player.xp = saveData.player.xp || 0;
player.level = saveData.player.level || 1;
// Обновляем spawnPoint на позицию из сохранения // Обновляем spawnPoint на позицию из сохранения
spawnPoint.x = player.x; spawnPoint.x = player.x;
@ -2295,25 +2616,11 @@
// клик по мобу (в режиме mine) // клик по мобу (в режиме mine)
if(mode()==='mine'){ if(mode()==='mine'){
// Check server mobs first (multiplayer) // Check all mobs (local + server-spawned) using getAllMobs
if(isMultiplayer){ const allClickMobs = getAllMobs();
for (const [id, sm] of serverMobs) { for(let i = allClickMobs.length - 1; i >= 0; i--){
if(sm.dead) continue; const m = allClickMobs[i];
if(wx>=sm.x && wx<=sm.x+sm.w && wy>=sm.y && wy<=sm.y+sm.h){ if(m.dead) continue;
let dmg = 1;
const swordTypes = ['iron_sword','stone_sword','wood_sword'];
for (const st of swordTypes) {
if (inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
}
socket.emit('mob_hurt', { id: sm.id, dmg });
playSound('attack');
return;
}
}
}
// Local mobs (singleplayer or if not hit server mob)
for(let i=mobs.length-1;i>=0;i--){
const m = mobs[i];
if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){ if(wx>=m.x && wx<=m.x+m.w && wy>=m.y && wy<=m.y+m.h){
let dmg = 1; let dmg = 1;
const swordTypes = ['iron_sword','stone_sword','wood_sword']; const swordTypes = ['iron_sword','stone_sword','wood_sword'];
@ -2324,14 +2631,24 @@
m.vx += (m.x - player.x) * 2; m.vx += (m.x - player.x) * 2;
m.vy -= 200; m.vy -= 200;
playSound('attack'); playSound('attack');
// Server-spawned mob: emit hurt to server for relay, handle death locally
if(m.id !== undefined && isMultiplayer){
socket.emit('mob_hurt', { id: m.id, dmg });
if(m.hp <= 0){
socket.emit('mob_died', { id: m.id });
}
}
if(m.hp<=0){ if(m.hp<=0){
if(m.kind === 'chicken') playSound('hurt_chicken'); if(m.kind === 'chicken') playSound('hurt_chicken');
inv.meat += (m.kind==='chicken' ? 1 : 2); spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
if(m.kind === 'skeleton'){ grantXP(getMobXP(m.kind));
inv.arrow += 2 + Math.floor(Math.random()*3); // Remove from the correct array
if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1; if(m.id !== undefined){
serverMobs.delete(m.id);
} else {
const localIdx = mobs.indexOf(m);
if(localIdx >= 0) mobs.splice(localIdx, 1);
} }
mobs.splice(i,1);
rebuildHotbar(); rebuildHotbar();
} }
return; return;
@ -3111,39 +3428,36 @@
continue; continue;
} }
} else { } else {
// Попал в моба — server mobs first in multiplayer // Попал в моба — check all mobs (client-authoritative)
let hitMob = false; const allArrowMobs = getAllMobs();
if(isMultiplayer){ for(let j = allArrowMobs.length - 1; j >= 0; j--){
for (const [id, sm] of serverMobs) { const m = allArrowMobs[j];
if(sm.dead) continue; if(m.dead) continue;
if(p.x > sm.x && p.x < sm.x+sm.w && p.y > sm.y && p.y < sm.y+sm.h){ if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx }); m.hp -= p.dmg;
projectiles.splice(i, 1); m.vx += p.vx * 0.2;
hitMob = true; m.vy -= 200;
break; // Server-spawned mob: emit arrow hit to server for relay
} if(m.id !== undefined && isMultiplayer){
} socket.emit('mob_arrow_hit', { id: m.id, dmg: p.dmg, vx: p.vx });
}
if(!hitMob){
// Local mobs
for(let j = mobs.length-1; j>=0; j--){
const m = mobs[j];
if(p.x > m.x && p.x < m.x+m.w && p.y > m.y && p.y < m.y+m.h){
m.hp -= p.dmg;
m.vx += p.vx * 0.2;
m.vy -= 200;
if(m.hp <= 0){ if(m.hp <= 0){
inv.meat += (m.kind==='chicken' ? 1 : 2); socket.emit('mob_died', { id: m.id });
if(m.kind === 'skeleton'){
inv.arrow += 2 + Math.floor(Math.random()*3);
if(Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
}
mobs.splice(j, 1);
rebuildHotbar();
} }
projectiles.splice(i, 1);
break;
} }
if(m.hp <= 0){
spawnDrops(m.x + m.w/2, m.y + m.h/2, m.kind);
grantXP(getMobXP(m.kind));
// Remove from the correct array
if(m.id !== undefined){
serverMobs.delete(m.id);
} else {
const localIdx = mobs.indexOf(m);
if(localIdx >= 0) mobs.splice(localIdx, 1);
}
rebuildHotbar();
}
projectiles.splice(i, 1);
break;
} }
} }
} }
@ -3162,9 +3476,9 @@
} }
} }
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме // mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events)
spawnT += dt; spawnT += dt;
if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){ if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){
spawnT = 0; spawnT = 0;
// Выбираем сторону спавна (левая или правая) // Выбираем сторону спавна (левая или правая)
@ -3206,13 +3520,28 @@
} }
} }
// mobs update — только локальные (singleplayer) // mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too)
if(!isMultiplayer){ {
// Local mobs
for(let i=mobs.length-1;i>=0;i--){ for(let i=mobs.length-1;i>=0;i--){
const m = mobs[i]; const m = mobs[i];
mobAI(m, dt); mobAI(m, dt);
if(m.hp<=0) mobs.splice(i,1); if(m.hp<=0) mobs.splice(i,1);
} }
// Server-spawned mobs (MP client-authoritative)
if(isMultiplayer){
for (const [id, sm] of serverMobs) {
mobAI(sm, dt);
if(sm.hp <= 0){
// Schedule removal (don't delete during iteration)
sm.dead = true;
}
}
// Remove dead server mobs
for (const [id, sm] of serverMobs) {
if(sm.dead) serverMobs.delete(id);
}
}
} }
// particles // particles
@ -3291,7 +3620,7 @@
} }
// mobs // mobs
const allMobsRender = isMultiplayer ? Array.from(serverMobs.values()) : mobs; const allMobsRender = getAllMobs();
for(const m of allMobsRender){ for(const m of allMobsRender){
if(m.kind==='zombie'){ if(m.kind==='zombie'){
ctx.fillStyle = '#2ecc71'; ctx.fillStyle = '#2ecc71';
@ -3486,10 +3815,10 @@
// Функция: рисуем мягкий луч света с затуханием за стенами // Функция: рисуем мягкий луч света с затуханием за стенами
function castLight(sx, sy, radius) { function castLight(sx, sy, radius) {
const flick = 0.88 + Math.sin(now/80 + sx*0.01)*0.06 + Math.sin(now/130 + sy*0.02)*0.06; const flick = 0.92 + Math.sin(now/80 + sx*0.01)*0.04 + Math.sin(now/130 + sy*0.02)*0.04;
const r = radius * flick; const r = radius * flick;
// 12 лучей — достаточно для мягкого круга // 24 луча — мягкий круглый свет
const steps = 12; const steps = 24;
// Собираем дистанции до стен по лучам // Собираем дистанции до стен по лучам
const dists = new Float32Array(steps); const dists = new Float32Array(steps);
for(let i=0; i<steps; i++){ for(let i=0; i<steps; i++){
@ -3498,7 +3827,7 @@
const dy = Math.sin(angle); const dy = Math.sin(angle);
let maxDist = r; let maxDist = r;
// Идём по лучу пока не упрёмся в стену // Идём по лучу пока не упрёмся в стену
for(let step=TILE*0.5; step<r; step+=TILE*0.6){ for(let step=TILE*0.3; step<r; step+=TILE*0.35){
const gx = Math.floor((sx + dx*step)/TILE); const gx = Math.floor((sx + dx*step)/TILE);
const gy = Math.floor((sy + dy*step)/TILE); const gy = Math.floor((sy + dy*step)/TILE);
const blk = getBlock(gx, gy); const blk = getBlock(gx, gy);
@ -3515,7 +3844,7 @@
const maxR = Math.max(...dists); const maxR = Math.max(...dists);
const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR); const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
grad.addColorStop(0, 'rgba(255,255,255,1)'); grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.65)'); grad.addColorStop(0.4, 'rgba(255,255,255,0.8)');
grad.addColorStop(1, 'rgba(255,255,255,0)'); grad.addColorStop(1, 'rgba(255,255,255,0)');
lightCtx.fillStyle = grad; lightCtx.fillStyle = grad;
// Рисуем shape по dists (звездоподобный полигон) // Рисуем shape по dists (звездоподобный полигон)
@ -3559,10 +3888,10 @@
const flick = 0.7 + Math.sin(now/90 + b.gx*3.7)*0.15 + Math.sin(now/140 + b.gy*2.3)*0.15; const flick = 0.7 + Math.sin(now/90 + b.gx*3.7)*0.15 + Math.sin(now/140 + b.gy*2.3)*0.15;
const wx = b.gx*TILE + TILE/2 - camX; const wx = b.gx*TILE + TILE/2 - camX;
const wy = b.gy*TILE + TILE/2 - camY; const wy = b.gy*TILE + TILE/2 - camY;
const r = def.lightRadius * 0.6 * flick; const r = def.lightRadius * 0.75 * flick;
const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r); const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
grad.addColorStop(0, `rgba(255,180,80,${0.12*flick})`); grad.addColorStop(0, `rgba(255,180,80,${0.22*flick})`);
grad.addColorStop(0.5, `rgba(255,140,40,${0.06*flick})`); grad.addColorStop(0.5, `rgba(255,140,40,${0.10*flick})`);
grad.addColorStop(1, 'rgba(255,100,20,0)'); grad.addColorStop(1, 'rgba(255,100,20,0)');
ctx.fillStyle = grad; ctx.fillStyle = grad;
ctx.beginPath(); ctx.beginPath();
@ -3603,6 +3932,13 @@
ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40); ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
} }
// Рисуем дропы
drawDrops(ctx);
// Пикап дропов
pickupDrops();
// Popup уровня
drawLevelUpPopup(ctx);
// Миникарта (обновляем раз в ~4 кадра для оптимизации) // Миникарта (обновляем раз в ~4 кадра для оптимизации)
if(minimapOpen && Math.random() < 0.25){ if(minimapOpen && Math.random() < 0.25){
renderMinimap(); renderMinimap();

97
index-new.html Normal file
View File

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>GrechkaCraft: Multiplayer</title>
<!-- Socket.io Client (loaded as regular script, provides window.io) -->
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<link rel="stylesheet" href="style.css?v=5">
</head>
<body>
<div id="game">
<canvas id="c"></canvas>
<div class="ui">
<div id="stats">
<div class="row">❤️ <span id="hp">100</span> &nbsp; 🍗 <span id="food">100</span></div>
<div class="row">🫁 <span id="o2">100</span></div>
<div class="row">📍 X:<span id="sx">0</span> Y:<span id="sy">0</span></div>
<div class="row">🕒 <span id="tod">День</span></div>
<div class="row">🌐 <span id="worldId" style="cursor:pointer; text-decoration:underline;" title="Нажмите, чтобы скопировать ссылку">default</span></div>
<div class="row" id="multiplayerStatus" style="display:none;">👥 <span id="playerCount">0</span></div>
</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="hotbar" class="pe"></div>
</div>
<!-- Миникарта -->
<div id="minimapWrap" style="display:none;position:absolute;left:10px;top:120px;z-index:200;pointer-events:auto;">
<canvas id="minimap" width="200" height="120" style="border:2px solid rgba(255,255,255,0.7);border-radius:8px;background:rgba(0,0,0,0.8);"></canvas>
</div>
<!-- Печь -->
<div id="furnacePanel" class="panel" style="display:none;">
<div class="panel-header">
<span>🔥 Печь</span>
<span id="furnaceClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="furnaceContent" style="padding:8px;"></div>
</div>
<div id="controls">
<div id="left" class="btn pe">⬅️</div>
<div id="jump" class="btn pe">⬆️</div>
<div id="down" class="btn pe">⬇️</div>
<div id="right" class="btn pe">➡️</div>
</div>
<div id="craftPanel" class="panel" style="display:none;">
<div class="panel-header">
<span>Крафт</span>
<span id="craftClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="recipes"></div>
</div>
<div id="inventoryPanel" class="panel" style="display:none;">
<div class="panel-header">
<span>Инвентарь</span>
<span id="inventoryClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="inventoryGrid"></div>
</div>
<div id="chatPanel" class="panel" style="display:none;">
<div class="panel-header">
<span>Чат</span>
<span id="chatClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="chatMessages"></div>
<div class="chat-input">
<input type="text" id="chatInput" placeholder="Введите сообщение...">
<button id="chatSend">Отправить</button>
</div>
</div>
<div id="death" class="death-screen" style="display:none;">
<div class="death-content">
<h1>💀 Вы погибли!</h1>
<button id="respawnBtn" class="respawn-btn">Возродиться</button>
</div>
</div>
</div>
<script type="module" src="src/main.js?v=9"></script>
</body>
</html>

97
index-old.html Normal file
View File

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>GrechkaCraft: Multiplayer</title>
<!-- Socket.io Client -->
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<link rel="stylesheet" href="style.css?v=5">
</head>
<body>
<div id="game">
<canvas id="c"></canvas>
<div class="ui">
<div id="stats">
<div class="row">❤️ <span id="hp">100</span> &nbsp; 🍗 <span id="food">100</span></div>
<div class="row">🫁 <span id="o2">100</span></div>
<div class="row">📍 X:<span id="sx">0</span> Y:<span id="sy">0</span></div>
<div class="row">🕒 <span id="tod">День</span></div>
<div class="row">🌐 <span id="worldId" style="cursor:pointer; text-decoration:underline;" title="Нажмите, чтобы скопировать ссылку">default</span></div>
<div class="row" id="multiplayerStatus" style="display:none;">👥 <span id="playerCount">0</span></div>
</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="hotbar" class="pe"></div>
</div>
<!-- Миникарта -->
<div id="minimapWrap" style="display:none;position:absolute;left:10px;top:120px;z-index:200;pointer-events:auto;">
<canvas id="minimap" width="200" height="120" style="border:2px solid rgba(255,255,255,0.7);border-radius:8px;background:rgba(0,0,0,0.8);"></canvas>
</div>
<!-- Печь -->
<div id="furnacePanel" class="panel" style="display:none;">
<div class="panel-header">
<span>🔥 Печь</span>
<span id="furnaceClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="furnaceContent" style="padding:8px;"></div>
</div>
<div id="controls">
<div id="left" class="btn pe">⬅️</div>
<div id="jump" class="btn pe">⬆️</div>
<div id="down" class="btn pe">⬇️</div>
<div id="right" class="btn pe">➡️</div>
</div>
<div id="craftPanel" class="panel" style="display:none;">
<div class="panel-header">
<span>Крафт</span>
<span id="craftClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="recipes"></div>
</div>
<div id="inventoryPanel" class="panel" style="display:none;">
<div class="panel-header">
<span>Инвентарь</span>
<span id="inventoryClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="inventoryGrid"></div>
</div>
<div id="chatPanel" class="panel" style="display:none;">
<div class="panel-header">
<span>Чат</span>
<span id="chatClose" class="close" style="cursor:pointer;"></span>
</div>
<div id="chatMessages"></div>
<div class="chat-input">
<input type="text" id="chatInput" placeholder="Введите сообщение...">
<button id="chatSend">Отправить</button>
</div>
</div>
<div id="death" class="death-screen" style="display:none;">
<div class="death-content">
<h1>💀 Вы погибли!</h1>
<button id="respawnBtn" class="respawn-btn">Возродиться</button>
</div>
</div>
</div>
<script src="game.js?v=8"></script>
</body>
</html>

View File

@ -6,7 +6,7 @@
<title>GrechkaCraft: Multiplayer</title> <title>GrechkaCraft: Multiplayer</title>
<!-- Socket.io Client --> <!-- Socket.io Client -->
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<link rel="stylesheet" href="style.css?v=5"> <link rel="stylesheet" href="style.css?v=6">
</head> </head>
<body> <body>
@ -20,6 +20,7 @@
<div class="row">📍 X:<span id="sx">0</span> Y:<span id="sy">0</span></div> <div class="row">📍 X:<span id="sx">0</span> Y:<span id="sy">0</span></div>
<div class="row">🕒 <span id="tod">День</span></div> <div class="row">🕒 <span id="tod">День</span></div>
<div class="row">🌐 <span id="worldId" style="cursor:pointer; text-decoration:underline;" title="Нажмите, чтобы скопировать ссылку">default</span></div> <div class="row">🌐 <span id="worldId" style="cursor:pointer; text-decoration:underline;" title="Нажмите, чтобы скопировать ссылку">default</span></div>
<div class="row">⭐ Lv.<span id="xplevel">1</span> | XP: <span id="xpbar">0/50</span></div>
<div class="row" id="multiplayerStatus" style="display:none;">👥 <span id="playerCount">0</span></div> <div class="row" id="multiplayerStatus" style="display:none;">👥 <span id="playerCount">0</span></div>
</div> </div>
@ -35,7 +36,7 @@
</div> </div>
<!-- Миникарта --> <!-- Миникарта -->
<div id="minimapWrap" style="display:none;position:absolute;left:10px;top:120px;z-index:200;pointer-events:auto;"> <div id="minimapWrap" style="display:none;position:absolute;left:190px;top:10px;z-index:200;pointer-events:auto;">
<canvas id="minimap" width="200" height="120" style="border:2px solid rgba(255,255,255,0.7);border-radius:8px;background:rgba(0,0,0,0.8);"></canvas> <canvas id="minimap" width="200" height="120" style="border:2px solid rgba(255,255,255,0.7);border-radius:8px;background:rgba(0,0,0,0.8);"></canvas>
</div> </div>
@ -92,6 +93,6 @@
</div> </div>
</div> </div>
<script src="game.js?v=8"></script> <script src="game.js?v=23"></script>
</body> </body>
</html> </html>

View File

@ -3,10 +3,15 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location ~* \.(js|css)$ { # CORS for ES modules
add_header Access-Control-Allow-Origin *;
location ~* \.(js|mjs|css)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
add_header Access-Control-Allow-Origin *;
expires 0; expires 0;
default_type application/javascript;
} }
location / { location / {

View File

@ -3,7 +3,7 @@ import { state } from '../core/state.js';
import { BLOCKS } from '../data/blocks.js'; import { BLOCKS } from '../data/blocks.js';
import { playSound } from '../audio/sound-engine.js'; import { playSound } from '../audio/sound-engine.js';
import { getBlock } from '../world/world-storage.js'; import { getBlock } from '../world/world-storage.js';
import { updateWaterPhysics } from '../world/water.js'; import { updateWaterPhysics } from '../physics/water.js';
import { updateWaterFlag } from '../physics/water-detect.js'; import { updateWaterFlag } from '../physics/water-detect.js';
import { resolveY, resolveX } from '../physics/collision.js'; import { resolveY, resolveX } from '../physics/collision.js';
import { calculateDamage } from '../entities/player.js'; import { calculateDamage } from '../entities/player.js';

View File

@ -25,7 +25,7 @@ import { initVoice } from './multiplayer/voice-chat.js';
import { resolveY, resolveX } from './physics/collision.js'; import { resolveY, resolveX } from './physics/collision.js';
import { calculateDamage } from './entities/player.js'; import { calculateDamage } from './entities/player.js';
import { updateWaterFlag } from './physics/water-detect.js'; import { updateWaterFlag } from './physics/water-detect.js';
import { updateWaterPhysics } from './world/water.js'; import { updateWaterPhysics } from './physics/water.js';
import { explodeAt, activateTNT } from './world/tnt.js'; import { explodeAt, activateTNT } from './world/tnt.js';
import { useTool } from './data/tools.js'; import { useTool } from './data/tools.js';
import { rebuildHotbar } from './ui/hotbar.js'; import { rebuildHotbar } from './ui/hotbar.js';

View File

@ -91,3 +91,16 @@ body.touch-device #hotbar {
#death { display:none; position:absolute; inset:0; background: rgba(60,0,0,0.88); #death { display:none; position:absolute; inset:0; background: rgba(60,0,0,0.88);
z-index:200; color:#fff; pointer-events:auto; align-items:center; justify-content:center; flex-direction:column; gap:12px; } z-index:200; color:#fff; pointer-events:auto; align-items:center; justify-content:center; flex-direction:column; gap:12px; }
#death button { padding:12px 18px; font-size:18px; font-weight:900; border:none; border-radius:12px; cursor:pointer; } #death button { padding:12px 18px; font-size:18px; font-weight:900; border:none; border-radius:12px; cursor:pointer; }
/* Panel header + close button fix */
.panel-header { display:flex; justify-content:space-between; align-items:center; color:#fff; font-weight:900; font-size:18px; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.15); }
.panel-header .close { background:#c0392b; border:none; color:#fff; font-weight:900; padding:8px 12px; border-radius:10px; cursor:pointer; font-size:16px; min-width:36px; text-align:center; flex-shrink:0; margin-left:12px; }
/* Custom modal (alerts/confirms) */
.custom-modal-overlay { position:absolute; inset:0; background:rgba(0,0,0,0.7); z-index:9999; display:flex; align-items:center; justify-content:center; }
.custom-modal-box { background:#1a1a2e; border:2px solid #e74c3c; border-radius:16px; padding:24px 32px; color:#fff; font-size:16px; font-weight:700; text-align:center; max-width:320px; box-shadow:0 8px 32px rgba(0,0,0,0.5); }
.custom-modal-box .modal-btns { display:flex; gap:10px; justify-content:center; margin-top:16px; }
.custom-modal-box button { font-weight:900; padding:10px 20px; border-radius:10px; font-size:15px; cursor:pointer; border:none; color:#fff; }
.custom-modal-box .btn-yes { background:#e74c3c; }
.custom-modal-box .btn-no { background:#555; }
.custom-modal-box .btn-ok { background:#2ecc71; }

70
voice-test.html Normal file
View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html><head><title>Voice Test</title></head><body>
<h1>Voice Capture Test</h1>
<button id="btn" style="padding:20px;font-size:24px;background:#2ecc71;color:#fff;border:none;border-radius:12px;cursor:pointer;">Start Mic</button>
<div id="log" style="font-family:monospace;white-space:pre;margin-top:20px;"></div>
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<script>
const log = document.getElementById('log');
function addLog(msg) { log.textContent += msg + '\n'; console.log(msg); }
let voiceStream, audioCtx, voiceProcessor, voiceSocket;
let debugCount = 0;
document.getElementById('btn').onclick = async () => {
try {
addLog('1. Requesting mic...');
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
addLog('2. Got stream: ' + voiceStream.getTracks().map(t => t.label + ' ' + t.readyState).join(', '));
audioCtx = new AudioContext({ sampleRate: 24000 });
if (audioCtx.state === 'suspended') await audioCtx.resume();
addLog('3. AudioContext: state=' + audioCtx.state + ' sampleRate=' + audioCtx.sampleRate);
const source = audioCtx.createMediaStreamSource(voiceStream);
voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
addLog('4. ScriptProcessor created');
voiceProcessor.onaudioprocess = (e) => {
debugCount++;
const pcm = e.inputBuffer.getChannelData(0);
const maxVal = Math.max(...Array.from(pcm).map(Math.abs));
if (debugCount <= 10) addLog('5. onaudioprocess #' + debugCount + ' samples=' + pcm.length + ' max=' + maxVal.toFixed(4));
if (!voiceSocket || !voiceSocket.connected) return;
const int16 = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i++) {
const s = Math.max(-1, Math.min(1, pcm[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
voiceSocket.emit('voice_data', int16.buffer);
};
const silentGain = audioCtx.createGain();
silentGain.gain.value = 0;
source.connect(voiceProcessor);
voiceProcessor.connect(silentGain);
silentGain.connect(audioCtx.destination);
addLog('6. Audio chain connected: source→processor→gain(0)→destination');
addLog('7. Connecting to voice server...');
voiceSocket = io('https://voicegrech.mkn8n.ru', { transports: ['websocket'] });
voiceSocket.on('connect', () => {
addLog('8. Socket connected: ' + voiceSocket.id);
voiceSocket.emit('voice_join', { world_id: 'test', x: 0, y: 0, name: 'Tester' });
addLog('9. Sent voice_join. Speak into mic — watch onaudioprocess logs above!');
});
voiceSocket.on('connect_error', (err) => addLog('ERROR: ' + err.message));
voiceSocket.on('voice_in', (payload) => {
addLog('VOICE_IN from ' + payload.meta.name + ' vol=' + payload.volume + ' bytes=' + payload.data.byteLength);
});
document.getElementById('btn').textContent = 'Listening...';
document.getElementById('btn').style.background = '#e74c3c';
} catch(e) {
addLog('ERROR: ' + e.message + '\n' + e.stack);
}
};
</script>
</body></html>