Compare commits
2 Commits
714e4cf162
...
8eebf378ab
| Author | SHA1 | Date |
|---|---|---|
|
|
8eebf378ab | |
|
|
81f6a0055a |
|
|
@ -4,7 +4,6 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY style.css /usr/share/nginx/html/style.css
|
||||
COPY game.js /usr/share/nginx/html/game.js
|
||||
COPY src/ /usr/share/nginx/html/src/
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
|
|||
|
|
@ -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 = '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
590
game.js
|
|
@ -1,48 +1,5 @@
|
|||
(() => {
|
||||
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
|
||||
// === 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
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||||
|
|
@ -127,45 +84,7 @@ function customConfirm(msg, onYes) {
|
|||
let socket = null;
|
||||
let isMultiplayer = false; // Флаг для мультиплеерного режима
|
||||
const otherPlayers = new Map(); // socket_id -> {x, y, color}
|
||||
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
|
||||
};
|
||||
}
|
||||
const serverMobs = new Map(); // id -> mob (server-authoritative in MP)
|
||||
let mySocketId = null;
|
||||
|
||||
// Throttle для отправки позиции (10-20 раз в секунду)
|
||||
|
|
@ -190,13 +109,6 @@ function customConfirm(msg, onYes) {
|
|||
|
||||
// Показываем в UI
|
||||
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';
|
||||
});
|
||||
|
||||
|
|
@ -307,11 +219,11 @@ function customConfirm(msg, onYes) {
|
|||
// Обновляем счётчик игроков
|
||||
playerCountEl.textContent = data.players.length;
|
||||
}
|
||||
// Server mobs — client-authoritative: create with full client-side properties
|
||||
// Server mobs
|
||||
if (data.mobs && Array.isArray(data.mobs)) {
|
||||
serverMobs.clear();
|
||||
for (const m of data.mobs) {
|
||||
const sm = createMobFromServer(m);
|
||||
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 };
|
||||
serverMobs.set(m.id, sm);
|
||||
}
|
||||
}
|
||||
|
|
@ -365,16 +277,14 @@ function customConfirm(msg, onYes) {
|
|||
// === MOB SYNC (multiplayer) ===
|
||||
|
||||
socket.on('mob_spawned', (data) => {
|
||||
const sm = createMobFromServer(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 };
|
||||
serverMobs.set(data.id, sm);
|
||||
});
|
||||
|
||||
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) {
|
||||
const sm = serverMobs.get(u.id);
|
||||
if (sm) { sm.hp = u.hp; sm.fuse = u.fuse != null ? u.fuse : sm.fuse; }
|
||||
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; }
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -385,8 +295,11 @@ function customConfirm(msg, onYes) {
|
|||
if (sm && data.killer === mySocketId) {
|
||||
// Give loot to the killer
|
||||
if (sm.kind === 'chicken') playSound('hurt_chicken');
|
||||
spawnDrops(sm.x, sm.y, sm.kind);
|
||||
grantXP(getMobXP(sm.kind));
|
||||
inv.meat += (sm.kind==='chicken' ? 1 : 2);
|
||||
if (sm.kind === 'skeleton') {
|
||||
inv.arrow += 2 + Math.floor(Math.random()*3);
|
||||
if (Math.random() < 0.15) inv.bow = (inv.bow||0) + 1;
|
||||
}
|
||||
rebuildHotbar();
|
||||
}
|
||||
serverMobs.delete(data.id);
|
||||
|
|
@ -648,8 +561,8 @@ function customConfirm(msg, onYes) {
|
|||
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
|
||||
brick: { n:'Кирпич', c:'#c0392b', solid:true },
|
||||
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
|
||||
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 },
|
||||
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 },
|
||||
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 },
|
||||
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
|
||||
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
|
||||
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
|
||||
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
|
||||
|
|
@ -661,10 +574,6 @@ function customConfirm(msg, onYes) {
|
|||
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
|
||||
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
|
||||
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 мира для детерминированной генерации
|
||||
|
|
@ -1162,7 +1071,7 @@ function customConfirm(msg, onYes) {
|
|||
}
|
||||
|
||||
// Мобы — красные (враждебные) / зелёные (животные)
|
||||
const allMobsForMap = getAllMobs();
|
||||
const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
|
||||
for (const m of allMobsForMap) {
|
||||
const dx = Math.floor(m.x / TILE) - startGX;
|
||||
const dy = Math.floor(m.y / TILE) - startGY;
|
||||
|
|
@ -1298,8 +1207,6 @@ function customConfirm(msg, onYes) {
|
|||
let audioCtx = null;
|
||||
let voiceProcessor = null;
|
||||
let voiceActive = false;
|
||||
let voiceMode = 'near'; // 'near' or 'world'
|
||||
let voiceDebugCount = 0;
|
||||
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
|
||||
|
||||
// Кнопка микрофона
|
||||
|
|
@ -1309,29 +1216,6 @@ function customConfirm(msg, onYes) {
|
|||
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;';
|
||||
|
|
@ -1343,7 +1227,6 @@ function customConfirm(msg, onYes) {
|
|||
if (voiceActive) {
|
||||
// Выключить
|
||||
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.style.background = '#555';
|
||||
if (voiceStream) {
|
||||
|
|
@ -1358,24 +1241,15 @@ function customConfirm(msg, onYes) {
|
|||
|
||||
// Включить
|
||||
try {
|
||||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
|
||||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, 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);
|
||||
voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||||
console.log('[voice] ScriptProcessor created, bufferSize=2048');
|
||||
voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
voiceProcessor.onaudioprocess = (e) => {
|
||||
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;
|
||||
if (!voiceActive || !voiceSocket || !voiceSocket.connected) return;
|
||||
const pcm = e.inputBuffer.getChannelData(0);
|
||||
// Конвертируем float32 → int16 для экономии трафика
|
||||
const int16 = new Int16Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, pcm[i]));
|
||||
|
|
@ -1384,116 +1258,54 @@ function customConfirm(msg, onYes) {
|
|||
voiceSocket.emit('voice_data', int16.buffer);
|
||||
};
|
||||
|
||||
// Chain: source → processor → gain(0) → destination
|
||||
// ScriptProcessor MUST reach destination to fire onaudioprocess
|
||||
const silentGain = audioCtx.createGain();
|
||||
silentGain.gain.value = 0;
|
||||
source.connect(voiceProcessor);
|
||||
// ScriptProcessor MUST connect to destination to fire onaudioprocess events
|
||||
// Use a zero-gain node to silence own playback while keeping the processor active
|
||||
const silentGain = audioCtx.createGain();
|
||||
silentGain.gain.value = 0; // mute — don't hear ourselves
|
||||
voiceProcessor.connect(silentGain);
|
||||
silentGain.connect(audioCtx.destination);
|
||||
console.log('[voice] Audio chain: source → processor → silentGain(0) → destination');
|
||||
|
||||
// Подключаемся к голосовому серверу
|
||||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||||
voiceSocket.on('connect', () => {
|
||||
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.emit('voice_join', { world_id: worldId, x: player.x, y: player.y, name: playerName || 'Игрок' });
|
||||
});
|
||||
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) => {
|
||||
// Пишем входящий голос в ring buffer
|
||||
// Воспроизводим входящий голос
|
||||
const { data, meta, volume } = payload;
|
||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||
|
||||
// Int16 → Float32
|
||||
const int16 = new Int16Array(data);
|
||||
const vol = Math.min(1.4, (volume || 1) * 1.5);
|
||||
const float32 = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
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);
|
||||
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume;
|
||||
}
|
||||
// Сброс jitter fill если пауза была
|
||||
jbufFill = ringReady;
|
||||
lastVoiceFrom = meta.name || '???';
|
||||
|
||||
const buf = audioCtx.createBuffer(1, float32.length, 24000);
|
||||
buf.getChannelData(0).set(float32);
|
||||
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.textContent = '🔊 ' + lastVoiceFrom;
|
||||
speakingIndicator.textContent = `🔊 ${meta.name}`;
|
||||
clearTimeout(speakingTimeout);
|
||||
speakingTimeout = setTimeout(() => {
|
||||
speakingIndicator.style.display = 'none';
|
||||
voicePlayActive = false; // сброс при паузе
|
||||
ringReady = 0; // очистить буфер
|
||||
ringRead = ringWrite; // синхронизировать
|
||||
}, 1500);
|
||||
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
|
||||
});
|
||||
|
||||
voiceActive = true;
|
||||
voiceBtn.textContent = '🎤';
|
||||
voiceBtn.style.background = '#2ecc71';
|
||||
console.log('[voice] Voice chat ACTIVE');
|
||||
} catch(e) {
|
||||
console.error('[voice] Error:', e);
|
||||
console.error('Voice error:', e);
|
||||
voiceBtn.style.background = '#e74c3c';
|
||||
}
|
||||
};
|
||||
|
|
@ -1537,16 +1349,11 @@ function customConfirm(msg, onYes) {
|
|||
s.appendChild(c);
|
||||
s.onclick = () => {
|
||||
playSound('click'); // Звук клика по инвентарю
|
||||
if(selected === id) {
|
||||
// Повторный клик — снимаем выбор, возвращаем к первому блоку
|
||||
selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
|
||||
} else {
|
||||
selected = id;
|
||||
// Обновляем список последних предметов
|
||||
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
|
||||
recentItems.unshift(id); // Добавляем в начало
|
||||
recentItems = recentItems.slice(0, 5); // Оставляем только 5
|
||||
}
|
||||
selected=id;
|
||||
// Обновляем список последних предметов
|
||||
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
|
||||
recentItems.unshift(id); // Добавляем в начало
|
||||
recentItems = recentItems.slice(0, 5); // Оставляем только 5
|
||||
rebuildHotbar();
|
||||
};
|
||||
|
||||
|
|
@ -1616,14 +1423,11 @@ function customConfirm(msg, onYes) {
|
|||
|
||||
slot.onclick = () => {
|
||||
playSound('click'); // Звук клика по инвентарю
|
||||
if(selected === id) {
|
||||
selected = recentItems.find(rid => BLOCKS[rid] && rid !== id) || recentItems[0] || 'dirt';
|
||||
} else {
|
||||
selected = id;
|
||||
recentItems = recentItems.filter(item => item !== id);
|
||||
recentItems.unshift(id);
|
||||
recentItems = recentItems.slice(0, 5);
|
||||
}
|
||||
selected = id;
|
||||
// Обновляем список последних предметов
|
||||
recentItems = recentItems.filter(item => item !== id); // Удаляем если уже есть
|
||||
recentItems.unshift(id); // Добавляем в начало
|
||||
recentItems = recentItems.slice(0, 5); // Оставляем только 5
|
||||
rebuildHotbar();
|
||||
renderInventory();
|
||||
};
|
||||
|
|
@ -1739,14 +1543,12 @@ function customConfirm(msg, onYes) {
|
|||
// Кнопка открытия инвентаря
|
||||
document.getElementById('invToggle').onclick = () => {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
inventoryOpen = !inventoryOpen;
|
||||
inventoryPanel.style.display = inventoryOpen ? 'block' : 'none';
|
||||
if(inventoryOpen) {
|
||||
renderInventory();
|
||||
// Закрываем крафт если открыт инвентарь
|
||||
craftOpen = false;
|
||||
craftPanel.style.display = 'none';
|
||||
}
|
||||
inventoryOpen = true;
|
||||
inventoryPanel.style.display = 'block';
|
||||
renderInventory();
|
||||
// Закрываем крафт если открыт инвентарь
|
||||
craftOpen = false;
|
||||
craftPanel.style.display = 'none';
|
||||
};
|
||||
|
||||
document.getElementById('inventoryClose').onclick = () => {
|
||||
|
|
@ -1760,13 +1562,13 @@ function customConfirm(msg, onYes) {
|
|||
saveBtn.onclick = () => {
|
||||
playSound('click');
|
||||
saveGame();
|
||||
customAlert('Игра сохранена!');
|
||||
alert('Игра сохранена!');
|
||||
};
|
||||
|
||||
// Кнопка сброса игры (удаление сохранения и создание нового мира)
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
resetBtn.onclick = () => {
|
||||
customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
|
||||
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
|
||||
playSound('click');
|
||||
|
||||
// Удаляем сохранение из localStorage
|
||||
|
|
@ -1800,7 +1602,7 @@ function customConfirm(msg, onYes) {
|
|||
|
||||
// Перезагружаем страницу
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Показываем кнопку сохранения только если играем одни
|
||||
|
|
@ -1894,130 +1696,11 @@ function customConfirm(msg, onYes) {
|
|||
sleeping: false,
|
||||
inBoat: false,
|
||||
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
|
||||
equippedArmor: null, // Тип надетой брони
|
||||
xp: 0,
|
||||
level: 1
|
||||
equippedArmor: null // Тип надетой брони
|
||||
};
|
||||
|
||||
// Сохраняем начальную позицию для возрождения
|
||||
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)
|
||||
const SAVE_KEY = 'minegrechka_save';
|
||||
|
|
@ -2047,9 +1730,7 @@ function customConfirm(msg, onYes) {
|
|||
y: player.y,
|
||||
hp: player.hp,
|
||||
hunger: player.hunger,
|
||||
o2: player.o2,
|
||||
xp: player.xp,
|
||||
level: player.level
|
||||
o2: player.o2
|
||||
},
|
||||
inventory: inv,
|
||||
time: worldTime,
|
||||
|
|
@ -2147,8 +1828,6 @@ function customConfirm(msg, onYes) {
|
|||
player.y = saveData.player.y;
|
||||
player.hunger = saveData.player.hunger;
|
||||
player.o2 = saveData.player.o2;
|
||||
player.xp = saveData.player.xp || 0;
|
||||
player.level = saveData.player.level || 1;
|
||||
|
||||
// Обновляем spawnPoint на позицию из сохранения
|
||||
spawnPoint.x = player.x;
|
||||
|
|
@ -2616,11 +2295,25 @@ function customConfirm(msg, onYes) {
|
|||
|
||||
// клик по мобу (в режиме mine)
|
||||
if(mode()==='mine'){
|
||||
// Check all mobs (local + server-spawned) using getAllMobs
|
||||
const allClickMobs = getAllMobs();
|
||||
for(let i = allClickMobs.length - 1; i >= 0; i--){
|
||||
const m = allClickMobs[i];
|
||||
if(m.dead) continue;
|
||||
// Check server mobs first (multiplayer)
|
||||
if(isMultiplayer){
|
||||
for (const [id, sm] of serverMobs) {
|
||||
if(sm.dead) continue;
|
||||
if(wx>=sm.x && wx<=sm.x+sm.w && wy>=sm.y && wy<=sm.y+sm.h){
|
||||
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){
|
||||
let dmg = 1;
|
||||
const swordTypes = ['iron_sword','stone_sword','wood_sword'];
|
||||
|
|
@ -2631,24 +2324,14 @@ function customConfirm(msg, onYes) {
|
|||
m.vx += (m.x - player.x) * 2;
|
||||
m.vy -= 200;
|
||||
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.kind === 'chicken') playSound('hurt_chicken');
|
||||
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);
|
||||
inv.meat += (m.kind==='chicken' ? 1 : 2);
|
||||
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(i,1);
|
||||
rebuildHotbar();
|
||||
}
|
||||
return;
|
||||
|
|
@ -3428,36 +3111,39 @@ function customConfirm(msg, onYes) {
|
|||
continue;
|
||||
}
|
||||
} else {
|
||||
// Попал в моба — check all mobs (client-authoritative)
|
||||
const allArrowMobs = getAllMobs();
|
||||
for(let j = allArrowMobs.length - 1; j >= 0; j--){
|
||||
const m = allArrowMobs[j];
|
||||
if(m.dead) continue;
|
||||
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;
|
||||
// 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 });
|
||||
// Попал в моба — server mobs first in multiplayer
|
||||
let hitMob = false;
|
||||
if(isMultiplayer){
|
||||
for (const [id, sm] of serverMobs) {
|
||||
if(sm.dead) continue;
|
||||
if(p.x > sm.x && p.x < sm.x+sm.w && p.y > sm.y && p.y < sm.y+sm.h){
|
||||
socket.emit('mob_arrow_hit', { id: sm.id, dmg: p.dmg, vx: p.vx });
|
||||
projectiles.splice(i, 1);
|
||||
hitMob = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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){
|
||||
socket.emit('mob_died', { id: m.id });
|
||||
inv.meat += (m.kind==='chicken' ? 1 : 2);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3476,9 +3162,9 @@ function customConfirm(msg, onYes) {
|
|||
}
|
||||
}
|
||||
|
||||
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events)
|
||||
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме
|
||||
spawnT += dt;
|
||||
if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){
|
||||
if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){
|
||||
spawnT = 0;
|
||||
|
||||
// Выбираем сторону спавна (левая или правая)
|
||||
|
|
@ -3520,28 +3206,13 @@ function customConfirm(msg, onYes) {
|
|||
}
|
||||
}
|
||||
|
||||
// mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too)
|
||||
{
|
||||
// Local mobs
|
||||
// mobs update — только локальные (singleplayer)
|
||||
if(!isMultiplayer){
|
||||
for(let i=mobs.length-1;i>=0;i--){
|
||||
const m = mobs[i];
|
||||
mobAI(m, dt);
|
||||
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
|
||||
|
|
@ -3620,7 +3291,7 @@ function customConfirm(msg, onYes) {
|
|||
}
|
||||
|
||||
// mobs
|
||||
const allMobsRender = getAllMobs();
|
||||
const allMobsRender = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
|
||||
for(const m of allMobsRender){
|
||||
if(m.kind==='zombie'){
|
||||
ctx.fillStyle = '#2ecc71';
|
||||
|
|
@ -3815,10 +3486,10 @@ function customConfirm(msg, onYes) {
|
|||
|
||||
// Функция: рисуем мягкий луч света с затуханием за стенами
|
||||
function castLight(sx, sy, radius) {
|
||||
const flick = 0.92 + Math.sin(now/80 + sx*0.01)*0.04 + Math.sin(now/130 + sy*0.02)*0.04;
|
||||
const flick = 0.88 + Math.sin(now/80 + sx*0.01)*0.06 + Math.sin(now/130 + sy*0.02)*0.06;
|
||||
const r = radius * flick;
|
||||
// 24 луча — мягкий круглый свет
|
||||
const steps = 24;
|
||||
// 12 лучей — достаточно для мягкого круга
|
||||
const steps = 12;
|
||||
// Собираем дистанции до стен по лучам
|
||||
const dists = new Float32Array(steps);
|
||||
for(let i=0; i<steps; i++){
|
||||
|
|
@ -3827,7 +3498,7 @@ function customConfirm(msg, onYes) {
|
|||
const dy = Math.sin(angle);
|
||||
let maxDist = r;
|
||||
// Идём по лучу пока не упрёмся в стену
|
||||
for(let step=TILE*0.3; step<r; step+=TILE*0.35){
|
||||
for(let step=TILE*0.5; step<r; step+=TILE*0.6){
|
||||
const gx = Math.floor((sx + dx*step)/TILE);
|
||||
const gy = Math.floor((sy + dy*step)/TILE);
|
||||
const blk = getBlock(gx, gy);
|
||||
|
|
@ -3844,7 +3515,7 @@ function customConfirm(msg, onYes) {
|
|||
const maxR = Math.max(...dists);
|
||||
const grad = lightCtx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
grad.addColorStop(0.4, 'rgba(255,255,255,0.8)');
|
||||
grad.addColorStop(0.5, 'rgba(255,255,255,0.65)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
lightCtx.fillStyle = grad;
|
||||
// Рисуем shape по dists (звездоподобный полигон)
|
||||
|
|
@ -3888,10 +3559,10 @@ function customConfirm(msg, onYes) {
|
|||
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 wy = b.gy*TILE + TILE/2 - camY;
|
||||
const r = def.lightRadius * 0.75 * flick;
|
||||
const r = def.lightRadius * 0.6 * flick;
|
||||
const grad = ctx.createRadialGradient(wx,wy, 0, wx,wy, r);
|
||||
grad.addColorStop(0, `rgba(255,180,80,${0.22*flick})`);
|
||||
grad.addColorStop(0.5, `rgba(255,140,40,${0.10*flick})`);
|
||||
grad.addColorStop(0, `rgba(255,180,80,${0.12*flick})`);
|
||||
grad.addColorStop(0.5, `rgba(255,140,40,${0.06*flick})`);
|
||||
grad.addColorStop(1, 'rgba(255,100,20,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
|
|
@ -3932,13 +3603,6 @@ function customConfirm(msg, onYes) {
|
|||
ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
|
||||
}
|
||||
|
||||
// Рисуем дропы
|
||||
drawDrops(ctx);
|
||||
// Пикап дропов
|
||||
pickupDrops();
|
||||
// Popup уровня
|
||||
drawLevelUpPopup(ctx);
|
||||
|
||||
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
|
||||
if(minimapOpen && Math.random() < 0.25){
|
||||
renderMinimap();
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
<!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> 🍗 <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>
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<!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> 🍗 <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>
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<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=6">
|
||||
<link rel="stylesheet" href="style.css?v=5">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -20,7 +20,6 @@
|
|||
<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">⭐ 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>
|
||||
|
||||
|
|
@ -36,7 +35,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Миникарта -->
|
||||
<div id="minimapWrap" style="display:none;position:absolute;left:190px;top:10px;z-index:200;pointer-events:auto;">
|
||||
<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>
|
||||
|
||||
|
|
@ -93,6 +92,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="game.js?v=23"></script>
|
||||
<script src="game.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,10 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# CORS for ES modules
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
|
||||
location ~* \.(js|mjs|css)$ {
|
||||
location ~* \.(js|css)$ {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
expires 0;
|
||||
default_type application/javascript;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { state } from '../core/state.js';
|
|||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { getBlock } from '../world/world-storage.js';
|
||||
import { updateWaterPhysics } from '../physics/water.js';
|
||||
import { updateWaterPhysics } from '../world/water.js';
|
||||
import { updateWaterFlag } from '../physics/water-detect.js';
|
||||
import { resolveY, resolveX } from '../physics/collision.js';
|
||||
import { calculateDamage } from '../entities/player.js';
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ 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 { updateWaterPhysics } from './world/water.js';
|
||||
import { explodeAt, activateTNT } from './world/tnt.js';
|
||||
import { useTool } from './data/tools.js';
|
||||
import { rebuildHotbar } from './ui/hotbar.js';
|
||||
|
|
|
|||
13
style.css
13
style.css
|
|
@ -91,16 +91,3 @@ body.touch-device #hotbar {
|
|||
#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; }
|
||||
#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; }
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
<!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>
|
||||
Loading…
Reference in New Issue