Compare commits
13 Commits
8eebf378ab
...
714e4cf162
| Author | SHA1 | Date |
|---|---|---|
|
|
714e4cf162 | |
|
|
2ee82a45b0 | |
|
|
0e12ed7a18 | |
|
|
233ff02976 | |
|
|
2ebb457fc5 | |
|
|
edb08094db | |
|
|
7eef966f6e | |
|
|
a59c84535a | |
|
|
5774a41761 | |
|
|
6b6125ae81 | |
|
|
cc46b93e96 | |
|
|
d3e2ebca78 | |
|
|
efcb5a0dd6 |
|
|
@ -4,6 +4,7 @@ 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;"]
|
||||
|
|
|
|||
557
game.js
557
game.js
|
|
@ -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
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||||
|
|
@ -84,7 +127,45 @@
|
|||
let socket = null;
|
||||
let isMultiplayer = false; // Флаг для мультиплеерного режима
|
||||
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;
|
||||
|
||||
// Throttle для отправки позиции (10-20 раз в секунду)
|
||||
|
|
@ -109,6 +190,13 @@
|
|||
|
||||
// Показываем в 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';
|
||||
});
|
||||
|
||||
|
|
@ -219,11 +307,11 @@
|
|||
// Обновляем счётчик игроков
|
||||
playerCountEl.textContent = data.players.length;
|
||||
}
|
||||
// Server mobs
|
||||
// Server mobs — client-authoritative: create with full client-side properties
|
||||
if (data.mobs && Array.isArray(data.mobs)) {
|
||||
serverMobs.clear();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -277,14 +365,16 @@
|
|||
// === MOB SYNC (multiplayer) ===
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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.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) {
|
||||
// Give loot to the killer
|
||||
if (sm.kind === 'chicken') playSound('hurt_chicken');
|
||||
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;
|
||||
}
|
||||
spawnDrops(sm.x, sm.y, sm.kind);
|
||||
grantXP(getMobXP(sm.kind));
|
||||
rebuildHotbar();
|
||||
}
|
||||
serverMobs.delete(data.id);
|
||||
|
|
@ -561,8 +648,8 @@
|
|||
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:190 },
|
||||
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
|
||||
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:280 },
|
||||
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:220 },
|
||||
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
|
||||
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
|
||||
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
|
||||
|
|
@ -574,6 +661,10 @@
|
|||
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 мира для детерминированной генерации
|
||||
|
|
@ -1071,7 +1162,7 @@
|
|||
}
|
||||
|
||||
// Мобы — красные (враждебные) / зелёные (животные)
|
||||
const allMobsForMap = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
|
||||
const allMobsForMap = getAllMobs();
|
||||
for (const m of allMobsForMap) {
|
||||
const dx = Math.floor(m.x / TILE) - startGX;
|
||||
const dy = Math.floor(m.y / TILE) - startGY;
|
||||
|
|
@ -1207,6 +1298,8 @@
|
|||
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';
|
||||
|
||||
// Кнопка микрофона
|
||||
|
|
@ -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;';
|
||||
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;';
|
||||
|
|
@ -1227,6 +1343,7 @@
|
|||
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) {
|
||||
|
|
@ -1241,15 +1358,24 @@
|
|||
|
||||
// Включить
|
||||
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 });
|
||||
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(4096, 1, 1);
|
||||
voiceProcessor = audioCtx.createScriptProcessor(2048, 1, 1);
|
||||
console.log('[voice] ScriptProcessor created, bufferSize=2048');
|
||||
|
||||
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);
|
||||
// Конвертируем 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]));
|
||||
|
|
@ -1258,49 +1384,116 @@
|
|||
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);
|
||||
voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
|
||||
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', () => {
|
||||
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) => {
|
||||
// Воспроизводим входящий голос
|
||||
// Пишем входящий голос в ring buffer
|
||||
const { data, meta, volume } = payload;
|
||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||
|
||||
// Int16 → Float32
|
||||
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++) {
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
// Сброс jitter fill если пауза была
|
||||
jbufFill = ringReady;
|
||||
lastVoiceFrom = meta.name || '???';
|
||||
|
||||
// Индикатор
|
||||
speakingIndicator.style.display = 'block';
|
||||
speakingIndicator.textContent = `🔊 ${meta.name}`;
|
||||
speakingIndicator.textContent = '🔊 ' + lastVoiceFrom;
|
||||
clearTimeout(speakingTimeout);
|
||||
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
|
||||
speakingTimeout = setTimeout(() => {
|
||||
speakingIndicator.style.display = 'none';
|
||||
voicePlayActive = false; // сброс при паузе
|
||||
ringReady = 0; // очистить буфер
|
||||
ringRead = ringWrite; // синхронизировать
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
|
@ -1344,11 +1537,16 @@
|
|||
s.appendChild(c);
|
||||
s.onclick = () => {
|
||||
playSound('click'); // Звук клика по инвентарю
|
||||
selected=id;
|
||||
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
|
||||
}
|
||||
rebuildHotbar();
|
||||
};
|
||||
|
||||
|
|
@ -1418,11 +1616,14 @@
|
|||
|
||||
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); // Оставляем только 5
|
||||
recentItems = recentItems.filter(item => item !== id);
|
||||
recentItems.unshift(id);
|
||||
recentItems = recentItems.slice(0, 5);
|
||||
}
|
||||
rebuildHotbar();
|
||||
renderInventory();
|
||||
};
|
||||
|
|
@ -1538,12 +1739,14 @@
|
|||
// Кнопка открытия инвентаря
|
||||
document.getElementById('invToggle').onclick = () => {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
inventoryOpen = true;
|
||||
inventoryPanel.style.display = 'block';
|
||||
inventoryOpen = !inventoryOpen;
|
||||
inventoryPanel.style.display = inventoryOpen ? 'block' : 'none';
|
||||
if(inventoryOpen) {
|
||||
renderInventory();
|
||||
// Закрываем крафт если открыт инвентарь
|
||||
craftOpen = false;
|
||||
craftPanel.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('inventoryClose').onclick = () => {
|
||||
|
|
@ -1557,13 +1760,13 @@
|
|||
saveBtn.onclick = () => {
|
||||
playSound('click');
|
||||
saveGame();
|
||||
alert('Игра сохранена!');
|
||||
customAlert('Игра сохранена!');
|
||||
};
|
||||
|
||||
// Кнопка сброса игры (удаление сохранения и создание нового мира)
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
resetBtn.onclick = () => {
|
||||
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
|
||||
customConfirm('Вы уверены, что хотите удалить сохранение и начать новую игру?', () => {
|
||||
playSound('click');
|
||||
|
||||
// Удаляем сохранение из localStorage
|
||||
|
|
@ -1597,7 +1800,7 @@
|
|||
|
||||
// Перезагружаем страницу
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Показываем кнопку сохранения только если играем одни
|
||||
|
|
@ -1691,12 +1894,131 @@
|
|||
sleeping: false,
|
||||
inBoat: false,
|
||||
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
|
||||
equippedArmor: null // Тип надетой брони
|
||||
equippedArmor: null, // Тип надетой брони
|
||||
xp: 0,
|
||||
level: 1
|
||||
};
|
||||
|
||||
// Сохраняем начальную позицию для возрождения
|
||||
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';
|
||||
let db = null; // Оставляем для совместимости, но не используем
|
||||
|
|
@ -1725,7 +2047,9 @@
|
|||
y: player.y,
|
||||
hp: player.hp,
|
||||
hunger: player.hunger,
|
||||
o2: player.o2
|
||||
o2: player.o2,
|
||||
xp: player.xp,
|
||||
level: player.level
|
||||
},
|
||||
inventory: inv,
|
||||
time: worldTime,
|
||||
|
|
@ -1823,6 +2147,8 @@
|
|||
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;
|
||||
|
|
@ -2290,25 +2616,11 @@
|
|||
|
||||
// клик по мобу (в режиме mine)
|
||||
if(mode()==='mine'){
|
||||
// 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];
|
||||
// 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;
|
||||
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'];
|
||||
|
|
@ -2319,14 +2631,24 @@
|
|||
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');
|
||||
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;
|
||||
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);
|
||||
}
|
||||
mobs.splice(i,1);
|
||||
rebuildHotbar();
|
||||
}
|
||||
return;
|
||||
|
|
@ -3106,34 +3428,32 @@
|
|||
continue;
|
||||
}
|
||||
} else {
|
||||
// Попал в моба — 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];
|
||||
// Попал в моба — 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 });
|
||||
if(m.hp <= 0){
|
||||
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;
|
||||
socket.emit('mob_died', { id: m.id });
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
mobs.splice(j, 1);
|
||||
rebuildHotbar();
|
||||
}
|
||||
projectiles.splice(i, 1);
|
||||
|
|
@ -3141,7 +3461,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Таймаут
|
||||
if(p.life <= 0) projectiles.splice(i, 1);
|
||||
|
|
@ -3157,9 +3476,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме
|
||||
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме (MP mobs come from server events)
|
||||
spawnT += dt;
|
||||
if(!isMultiplayer && spawnT > 1.8 && mobs.length < 30){
|
||||
if(!isMultiplayer && spawnT > 1.8 && getAllMobs().length < 30){
|
||||
spawnT = 0;
|
||||
|
||||
// Выбираем сторону спавна (левая или правая)
|
||||
|
|
@ -3201,13 +3520,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
// mobs update — только локальные (singleplayer)
|
||||
if(!isMultiplayer){
|
||||
// mobs update — mobAI runs on ALL mobs (client-authoritative for server-spawned mobs too)
|
||||
{
|
||||
// Local mobs
|
||||
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
|
||||
|
|
@ -3286,7 +3620,7 @@
|
|||
}
|
||||
|
||||
// mobs
|
||||
const allMobsRender = isMultiplayer ? Array.from(serverMobs.values()) : mobs;
|
||||
const allMobsRender = getAllMobs();
|
||||
for(const m of allMobsRender){
|
||||
if(m.kind==='zombie'){
|
||||
ctx.fillStyle = '#2ecc71';
|
||||
|
|
@ -3481,10 +3815,10 @@
|
|||
|
||||
// Функция: рисуем мягкий луч света с затуханием за стенами
|
||||
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;
|
||||
// 12 лучей — достаточно для мягкого круга
|
||||
const steps = 12;
|
||||
// 24 луча — мягкий круглый свет
|
||||
const steps = 24;
|
||||
// Собираем дистанции до стен по лучам
|
||||
const dists = new Float32Array(steps);
|
||||
for(let i=0; i<steps; i++){
|
||||
|
|
@ -3493,7 +3827,7 @@
|
|||
const dy = Math.sin(angle);
|
||||
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 gy = Math.floor((sy + dy*step)/TILE);
|
||||
const blk = getBlock(gx, gy);
|
||||
|
|
@ -3510,7 +3844,7 @@
|
|||
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.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)');
|
||||
lightCtx.fillStyle = grad;
|
||||
// Рисуем shape по dists (звездоподобный полигон)
|
||||
|
|
@ -3554,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 wx = b.gx*TILE + TILE/2 - camX;
|
||||
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);
|
||||
grad.addColorStop(0, `rgba(255,180,80,${0.12*flick})`);
|
||||
grad.addColorStop(0.5, `rgba(255,140,40,${0.06*flick})`);
|
||||
grad.addColorStop(0, `rgba(255,180,80,${0.22*flick})`);
|
||||
grad.addColorStop(0.5, `rgba(255,140,40,${0.10*flick})`);
|
||||
grad.addColorStop(1, 'rgba(255,100,20,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
|
|
@ -3598,6 +3932,13 @@
|
|||
ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
|
||||
}
|
||||
|
||||
// Рисуем дропы
|
||||
drawDrops(ctx);
|
||||
// Пикап дропов
|
||||
pickupDrops();
|
||||
// Popup уровня
|
||||
drawLevelUpPopup(ctx);
|
||||
|
||||
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
|
||||
if(minimapOpen && Math.random() < 0.25){
|
||||
renderMinimap();
|
||||
|
|
|
|||
|
|
@ -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> 🍗 <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>
|
||||
|
|
@ -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> 🍗 <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=5">
|
||||
<link rel="stylesheet" href="style.css?v=6">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
<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>
|
||||
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
</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>
|
||||
</div>
|
||||
|
||||
|
|
@ -92,6 +93,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="game.js?v=8"></script>
|
||||
<script src="game.js?v=23"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@ server {
|
|||
root /usr/share/nginx/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 Pragma "no-cache";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
expires 0;
|
||||
default_type application/javascript;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
// Звуковой движок
|
||||
const sounds = {};
|
||||
|
||||
export { sounds };
|
||||
|
||||
export function loadSound(id, src) {
|
||||
const audio = new Audio();
|
||||
audio.src = src;
|
||||
audio.volume = 0.3;
|
||||
sounds[id] = audio;
|
||||
}
|
||||
|
||||
// Загрузка звуков
|
||||
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
|
||||
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
|
||||
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
|
||||
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
|
||||
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
|
||||
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
|
||||
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
|
||||
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
|
||||
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
|
||||
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
|
||||
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
|
||||
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
|
||||
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
|
||||
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
|
||||
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
|
||||
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
|
||||
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
|
||||
|
||||
export function playSound(id) {
|
||||
if (sounds[id]) {
|
||||
sounds[id].currentTime = 0;
|
||||
sounds[id].play().catch(e => console.error('Sound error:', e));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// ==================== КОНФИГУРАЦИЯ СЕРВЕРА ====================
|
||||
// Возможность переопределить сервер через query string
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
export const SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||||
|
||||
// Защита от mixed content
|
||||
if (location.protocol === 'https:' && SERVER_URL.startsWith('http://')) {
|
||||
console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
|
||||
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
|
||||
}
|
||||
|
||||
// ==================== WORLD ID И ИГРОКА ====================
|
||||
let worldId = null;
|
||||
let playerName = localStorage.getItem('minegrechka_playerName') || null;
|
||||
|
||||
// Запрашиваем имя игрока, если его нет
|
||||
if (!playerName) {
|
||||
playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
|
||||
localStorage.setItem('minegrechka_playerName', playerName);
|
||||
console.log('Player name set:', playerName);
|
||||
}
|
||||
|
||||
// Берём worldId из URL или генерируем новый
|
||||
console.log('Current URL:', window.location.href);
|
||||
const worldParam = urlParams.get('world');
|
||||
console.log('world param:', worldParam);
|
||||
|
||||
// Проверяем на null, undefined или пустую строку
|
||||
worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
|
||||
|
||||
console.log('worldId after params:', worldId, 'type:', typeof worldId);
|
||||
|
||||
// Если worldId отсутствует - генерируем новый и записываем в URL
|
||||
if (!worldId) {
|
||||
worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Generated worldId:', worldId);
|
||||
|
||||
try {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('world', worldId);
|
||||
const newUrlString = newUrl.toString();
|
||||
console.log('New URL to set:', newUrlString);
|
||||
|
||||
// Проверяем, поддерживается ли history API
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrlString);
|
||||
console.log('URL after replaceState:', window.location.href);
|
||||
console.log('URL after replaceState (direct check):', window.location.search);
|
||||
} else {
|
||||
console.error('History API not supported!');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error updating URL:', e);
|
||||
}
|
||||
|
||||
console.log('Generated new worldId for browser:', worldId);
|
||||
}
|
||||
|
||||
console.log('Final worldId:', worldId, 'Player name:', playerName);
|
||||
|
||||
console.log(`Server URL: ${SERVER_URL}, World ID: ${worldId}`);
|
||||
|
||||
// Setters для изменения worldId/playerName из других модулей
|
||||
export function setWorldId(id) { worldId = id; }
|
||||
export function setPlayerName(name) { playerName = name; }
|
||||
|
||||
export { worldId, playerName };
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// ==================== ХОЛСТ И РАЗМЕРЫ ====================
|
||||
|
||||
const gameEl = document.getElementById('game');
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// offscreen light map (не вставляем в DOM)
|
||||
const lightC = document.createElement('canvas');
|
||||
const lightCtx = lightC.getContext('2d');
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
let W = 0, H = 0;
|
||||
|
||||
function resize() {
|
||||
W = gameEl.clientWidth;
|
||||
H = gameEl.clientHeight;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
lightC.width = W * dpr;
|
||||
lightC.height = H * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
function setW(v) { W = v; }
|
||||
function setH(v) { H = v; }
|
||||
|
||||
export { canvas, ctx, lightC, lightCtx, dpr, W, H, gameEl, resize, setW, setH };
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// ==================== КОНСТАНТЫ ИГРЫ ====================
|
||||
|
||||
export const TILE = 40;
|
||||
|
||||
// Мир
|
||||
export const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
|
||||
export const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
|
||||
export const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
|
||||
|
||||
// Физика
|
||||
export const GRAV = 2200;
|
||||
export const GRAV_WATER = 550;
|
||||
export const MOVE = 320;
|
||||
export const JUMP = 760;
|
||||
|
||||
// День/ночь
|
||||
export const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
|
||||
|
||||
// Сохранение
|
||||
export const SAVE_KEY = 'minegrechka_save';
|
||||
|
||||
// Сеть
|
||||
export const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
|
||||
|
||||
// Погода
|
||||
export const MAX_RAINDROPS = 200;
|
||||
|
||||
// Чат
|
||||
export const MAX_CHAT_MESSAGES = 50;
|
||||
|
||||
// Голосовой чат
|
||||
export const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { TILE } from './constants.js';
|
||||
|
||||
// Все мутабельные переменные игры в одном объекте состояния
|
||||
export const state = {
|
||||
// Камера
|
||||
camX: 0,
|
||||
camY: 0,
|
||||
|
||||
// День/ночь
|
||||
worldTime: 0,
|
||||
isNightTime: false,
|
||||
|
||||
// Мультиплеер
|
||||
isMultiplayer: false,
|
||||
mySocketId: null,
|
||||
socket: null,
|
||||
|
||||
// Инвентарь/UI
|
||||
selected: 0,
|
||||
showFullInventory: false,
|
||||
craftOpen: false,
|
||||
inventoryOpen: false,
|
||||
chatOpen: false,
|
||||
modeIdx: 0,
|
||||
|
||||
// Мир
|
||||
worldSeed: Math.floor(Math.random() * 1000000),
|
||||
|
||||
// Погода
|
||||
isRaining: false,
|
||||
rainIntensity: 0,
|
||||
weatherTimer: 0,
|
||||
weatherChangeInterval: 60 + Math.random() * 120,
|
||||
|
||||
// Мобы/спавн
|
||||
spawnT: 0,
|
||||
|
||||
// Цикл
|
||||
last: 0,
|
||||
prevJump: false,
|
||||
|
||||
// Сеть — throttle отправки позиции
|
||||
lastMoveSendTime: 0,
|
||||
lastSentX: 0,
|
||||
lastSentY: 0,
|
||||
|
||||
// Игрок
|
||||
player: {
|
||||
x: 6 * TILE,
|
||||
y: 0 * TILE,
|
||||
w: 34,
|
||||
h: 34,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
grounded: false,
|
||||
inWater: false,
|
||||
headInWater: false,
|
||||
hp: 100,
|
||||
hunger: 100,
|
||||
o2: 100,
|
||||
invuln: 0,
|
||||
fallStartY: 0,
|
||||
lastStepTime: 0,
|
||||
sleeping: false,
|
||||
inBoat: false,
|
||||
armor: 0,
|
||||
equippedArmor: null
|
||||
},
|
||||
|
||||
// Точка спавна
|
||||
spawnPoint: { x: 6 * TILE, y: 0 * TILE },
|
||||
|
||||
// Инвентарь
|
||||
inv: {
|
||||
dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0,
|
||||
wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0,
|
||||
copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0,
|
||||
brick: 0, glass: 0,
|
||||
tnt: 1, campfire: 0, torch: 0,
|
||||
meat: 0, cooked: 0, arrow: 0,
|
||||
wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0,
|
||||
wood_sword: 0, stone_sword: 0, iron_sword: 0,
|
||||
iron_armor: 0,
|
||||
bow: 0, furnace: 0,
|
||||
bed: 0, boat: 0,
|
||||
iron_ingot: 0, gold_ingot: 0, copper_ingot: 0
|
||||
},
|
||||
|
||||
// Лодка
|
||||
boat: {
|
||||
x: 0, y: 0,
|
||||
w: 34, h: 34,
|
||||
vx: 0, vy: 0,
|
||||
active: false,
|
||||
inWater: false
|
||||
},
|
||||
|
||||
// Ввод
|
||||
inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false },
|
||||
|
||||
// Мышь
|
||||
mouse: { x: 0, y: 0 },
|
||||
|
||||
// Другие игроки (MP)
|
||||
otherPlayers: new Map(),
|
||||
|
||||
// Серверные мобы (MP)
|
||||
serverMobs: new Map(),
|
||||
|
||||
// Мобы
|
||||
mobs: [],
|
||||
|
||||
// Снаряды
|
||||
projectiles: [],
|
||||
|
||||
// Отслеживание изменений мира
|
||||
placedBlocks: [],
|
||||
removedBlocks: [],
|
||||
|
||||
// Серверные изменения
|
||||
serverOverrides: new Map(),
|
||||
|
||||
// Чат
|
||||
chatMessages: [],
|
||||
|
||||
// Погода — капли
|
||||
raindrops: [],
|
||||
|
||||
// Облака
|
||||
clouds: Array.from({ length: 10 }, () => ({
|
||||
x: Math.random() * 2000,
|
||||
y: -200 - Math.random() * 260,
|
||||
w: 80 + Math.random() * 120,
|
||||
s: 12 + Math.random() * 20
|
||||
})),
|
||||
|
||||
// Частицы
|
||||
parts: [],
|
||||
|
||||
// Активный TNT
|
||||
activeTNT: new Set(),
|
||||
|
||||
// Прочность инструментов
|
||||
toolDurability: new Map(),
|
||||
|
||||
// Последние выбранные предметы
|
||||
recentItems: [],
|
||||
|
||||
// Активные печи
|
||||
activeFurnaces: new Map(),
|
||||
|
||||
// Сгенерированные колонны
|
||||
generated: new Set(),
|
||||
|
||||
// Изображение героя
|
||||
heroImg: null
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
export const BLOCKS = {
|
||||
air: { n:'Воздух', solid:false },
|
||||
grass: { n:'Трава', c:'#7cfc00', solid:true },
|
||||
dirt: { n:'Грязь', c:'#8b4513', solid:true },
|
||||
stone: { n:'Камень', c:'#7f8c8d', solid:true },
|
||||
sand: { n:'Песок', c:'#f4d06f', solid:true },
|
||||
gravel: { n:'Гравий', c:'#95a5a6', solid:true },
|
||||
clay: { n:'Глина', c:'#74b9ff', solid:true },
|
||||
wood: { n:'Дерево', c:'#d35400', solid:true },
|
||||
planks: { n:'Доски', c:'#e67e22', solid:true },
|
||||
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
|
||||
leaves: { n:'Листва', c:'#2ecc71', solid:true },
|
||||
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
|
||||
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
|
||||
coal: { n:'Уголь', c:'#2c3e50', solid:true },
|
||||
copper_ore:{ n:'Медь', c:'#e17055', solid:true },
|
||||
iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
|
||||
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
|
||||
gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
|
||||
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: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 },
|
||||
boat: { n:'Лодка', c:'#8B4513', solid:false },
|
||||
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true }
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export const ITEMS = {
|
||||
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
|
||||
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
|
||||
arrow: { n:'Стрела', icon:'➡️', stack:64 },
|
||||
};
|
||||
|
||||
// Runtime extensions — предметы от обжига
|
||||
ITEMS.iron_ingot = { n:'Железный слиток', icon:'🔩' };
|
||||
ITEMS.gold_ingot = { n:'Золотой слиток', icon:'🪙' };
|
||||
ITEMS.copper_ingot = { n:'Медный слиток', icon:'🟤' };
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
export const RECIPES = [
|
||||
{ out:'planks', qty:4, cost:{ wood:1 } },
|
||||
{ out:'ladder', qty:3, cost:{ planks:7 } },
|
||||
{ out:'torch', qty:2, cost:{ coal:1, planks:1 } },
|
||||
{ out:'glass', qty:1, cost:{ sand:3 } },
|
||||
{ out:'brick', qty:1, cost:{ stone:2, clay:1 } },
|
||||
{ out:'campfire', qty:1, cost:{ wood:1, coal:1 } },
|
||||
{ out:'tnt', qty:1, cost:{ sand:2, coal:1 } },
|
||||
{ out:'bed', qty:1, cost:{ wood: 3, planks: 3 } },
|
||||
{ out:'boat', qty:1, cost:{ wood: 5 } },
|
||||
{ out:'wood_pickaxe', qty:1, cost:{ wood: 3, planks: 2 } },
|
||||
{ out:'stone_pickaxe', qty:1, cost:{ wood: 1, stone: 3 } },
|
||||
{ out:'iron_pickaxe', qty:1, cost:{ wood: 1, iron_ore: 2 } },
|
||||
{ out:'wood_sword', qty:1, cost:{ wood: 2, planks: 1 } },
|
||||
{ out:'stone_sword', qty:1, cost:{ wood: 1, stone: 2 } },
|
||||
{ out:'iron_sword', qty:1, cost:{ wood: 1, iron_ore: 1 } },
|
||||
{ out:'iron_armor', qty:1, cost:{ iron_ore: 5 } },
|
||||
{ out:'furnace', qty:1, cost:{ stone: 8 } },
|
||||
{ out:'bow', qty:1, cost:{ wood: 3, planks: 2 } },
|
||||
{ out:'arrow', qty:4, cost:{ stone: 1, wood: 1 } }
|
||||
];
|
||||
|
||||
// Рецепты печи (обжиг)
|
||||
export const SMELTING_RECIPES = [
|
||||
{ in:'sand', qty:1, out:'glass', outQty:1, time:3 }, // песок → стекло
|
||||
{ in:'clay', qty:1, out:'brick', outQty:1, time:3 }, // глина → кирпич
|
||||
{ in:'iron_ore', qty:1, out:'iron_ingot', outQty:1, time:5 }, // железо → слиток
|
||||
{ in:'gold_ore', qty:1, out:'gold_ingot', outQty:1, time:5 }, // золото → слиток
|
||||
{ in:'copper_ore', qty:1, out:'copper_ingot', outQty:1, time:4 }, // медь → слиток
|
||||
{ in:'meat', qty:1, out:'cooked', outQty:1, time:2 }, // сырое мясо → жареное
|
||||
{ in:'cobblestone', qty:1, out:'stone', outQty:1, time:2 } // булыжник → камень
|
||||
];
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
export const TOOLS = {
|
||||
wood_pickaxe: { n:'Деревянная кирка', icon:'⛏️', durability: 60, miningPower: 1, craft: { wood: 3, planks: 2 } },
|
||||
stone_pickaxe: { n:'Каменная кирка', icon:'⛏️', durability: 130, miningPower: 2, craft: { wood: 1, stone: 3 } },
|
||||
iron_pickaxe: { n:'Железная кирка', icon:'⛏️', durability: 250, miningPower: 3, craft: { wood: 1, iron_ore: 2 } },
|
||||
wood_sword: { n:'Деревянный меч', icon:'⚔️', durability: 40, damage: 5, craft: { wood: 2, planks: 1 } },
|
||||
stone_sword: { n:'Каменный меч', icon:'⚔️', durability: 100, damage: 8, craft: { wood: 1, stone: 2 } },
|
||||
iron_sword: { n:'Железный меч', icon:'⚔️', durability: 200, damage: 12, craft: { wood: 1, iron_ore: 1 } },
|
||||
bow: { n:'Лук', icon:'🏹', durability: 150, craft: { wood: 3, string: 0 } }
|
||||
};
|
||||
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
export function addTool(type) {
|
||||
const maxDur = TOOLS[type]?.durability || 60;
|
||||
const key = `${type}_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;
|
||||
state.toolDurability.set(key, { type, current: maxDur, max: maxDur });
|
||||
}
|
||||
|
||||
export function useTool(type) {
|
||||
for (const [tid, dur] of state.toolDurability) {
|
||||
if (dur.type === type && dur.current > 0) {
|
||||
dur.current--;
|
||||
if (dur.current <= 0) {
|
||||
state.toolDurability.delete(tid);
|
||||
state.inv[type] = Math.max(0, (state.inv[type] || 0) - 1);
|
||||
return true; // broke
|
||||
}
|
||||
return false; // used but not broke
|
||||
}
|
||||
}
|
||||
return false; // no durability entry found
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Лодка
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
export const boat = {
|
||||
x: 0, y: 0,
|
||||
w: 34, h: 34,
|
||||
vx: 0, vy: 0,
|
||||
active: false,
|
||||
inWater: false
|
||||
};
|
||||
|
||||
export function initBoat() {
|
||||
boat.x = 0;
|
||||
boat.y = 0;
|
||||
boat.vx = 0;
|
||||
boat.vy = 0;
|
||||
boat.active = false;
|
||||
boat.inWater = false;
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// Моб AI
|
||||
import { state } from '../core/state.js';
|
||||
import { GRAV, GRAV_WATER, TILE } from '../core/constants.js';
|
||||
import { updateWaterFlag } from '../physics/water-detect.js';
|
||||
import { calculateDamage } from '../entities/player.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { explodeAt } from '../world/tnt.js';
|
||||
import { resolveY } from '../physics/collision.js';
|
||||
import { resolveX } from '../physics/collision.js';
|
||||
|
||||
export function mobAI(m, dt) {
|
||||
updateWaterFlag(m);
|
||||
|
||||
if (m.kind === 'zombie') {
|
||||
// активность ночью
|
||||
const night = isNight();
|
||||
if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
|
||||
const dir = Math.sign((state.player.x) - m.x);
|
||||
m.vx = dir * m.speed;
|
||||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||||
// атака
|
||||
if (Math.abs((m.x + m.w / 2) - (state.player.x + state.player.w / 2)) < 28 &&
|
||||
Math.abs((m.y + m.h / 2) - (state.player.y + state.player.h / 2)) < 40 &&
|
||||
state.player.invuln <= 0) {
|
||||
const damage = calculateDamage(15);
|
||||
state.player.hp -= damage;
|
||||
state.player.invuln = 0.8;
|
||||
state.player.vx += dir * 420;
|
||||
state.player.vy -= 260;
|
||||
playSound('hit1'); // Звук при атаке зомби
|
||||
}
|
||||
} else if (m.kind === 'creeper') {
|
||||
// активность ночью
|
||||
const night = isNight();
|
||||
if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
|
||||
const dir = Math.sign((state.player.x) - m.x);
|
||||
const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2));
|
||||
|
||||
// Движение к игроку
|
||||
m.vx = dir * m.speed;
|
||||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||||
|
||||
// Взрыв если близко к игроку
|
||||
if (dist < 60) {
|
||||
m.fuse -= dt;
|
||||
if (m.fuse <= 0) {
|
||||
explodeAt(Math.floor((m.x + m.w / 2) / TILE), Math.floor((m.y + m.h / 2) / TILE));
|
||||
m.hp = 0;
|
||||
}
|
||||
} else {
|
||||
// Поджигаем если очень близко
|
||||
if (dist < 40) {
|
||||
m.fuse = 0.5; // Быстрый взрыв
|
||||
}
|
||||
}
|
||||
} else if (m.kind === 'skeleton') {
|
||||
// активность ночью
|
||||
const night = isNight();
|
||||
if (!night) { m.hp -= 30 * dt; m.vx *= 0.5; return; }
|
||||
const dir = Math.sign((state.player.x) - m.x);
|
||||
const dist = Math.hypot((state.player.x + state.player.w / 2) - (m.x + m.w / 2), (state.player.y + state.player.h / 2) - (m.y + m.h / 2));
|
||||
|
||||
// Движение к игроку
|
||||
m.vx = dir * m.speed;
|
||||
if (m.inWater && Math.random() < 0.06) m.vy = -260;
|
||||
|
||||
// Стрельба стрелами
|
||||
m.shootCooldown -= dt;
|
||||
if (dist < 300 && m.shootCooldown <= 0) {
|
||||
m.shootCooldown = 2.0;
|
||||
const dx = (state.player.x + state.player.w / 2) - (m.x + m.w / 2);
|
||||
const dy = (state.player.y + state.player.h / 2) - (m.y + m.h / 2);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const speed = 450;
|
||||
state.projectiles.push({
|
||||
x: m.x + m.w / 2,
|
||||
y: m.y + m.h / 3,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
dmg: 6,
|
||||
owner: 'mob',
|
||||
life: 3
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// животные
|
||||
m.aiT -= dt;
|
||||
if (m.aiT <= 0) {
|
||||
m.aiT = 1.8 + Math.random() * 2.5;
|
||||
m.dir = Math.random() < 0.5 ? -1 : 1;
|
||||
if (Math.random() < 0.25) m.dir = 0;
|
||||
}
|
||||
m.vx = m.dir * (m.kind === 'chicken' ? 55 : 40);
|
||||
if (m.inWater) m.vy = -120;
|
||||
}
|
||||
|
||||
// физика моба
|
||||
const g = m.inWater ? GRAV_WATER : GRAV;
|
||||
m.vy += g * dt;
|
||||
|
||||
m.y += m.vy * dt; m.grounded = false; resolveY(m);
|
||||
m.x += m.vx * dt; resolveX(m);
|
||||
}
|
||||
|
||||
export function isNight() {
|
||||
// Автоматический цикл: ночь когда worldTime > 0.5
|
||||
return state.worldTime > 0.5;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// Сущности: животные + зомби
|
||||
|
||||
export class Entity {
|
||||
constructor(x, y, w, h) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
this.hp = 3;
|
||||
this.grounded = false;
|
||||
this.inWater = false;
|
||||
this.aiT = 0;
|
||||
this.dir = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class Pig extends Entity {
|
||||
constructor(x, y) { super(x, y, 34, 24); this.kind = 'pig'; this.hp = 2; }
|
||||
}
|
||||
|
||||
export class Chicken extends Entity {
|
||||
constructor(x, y) { super(x, y, 26, 22); this.kind = 'chicken'; this.hp = 1; }
|
||||
}
|
||||
|
||||
export class Zombie extends Entity {
|
||||
constructor(x, y) { super(x, y, 34, 50); this.kind = 'zombie'; this.hp = 4; this.speed = 80 + Math.random() * 40; }
|
||||
}
|
||||
|
||||
export class Creeper extends Entity {
|
||||
constructor(x, y) { super(x, y, 34, 50); this.kind = 'creeper'; this.hp = 4; this.speed = 60 + Math.random() * 30; this.fuse = 3.2; }
|
||||
}
|
||||
|
||||
export class Skeleton extends Entity {
|
||||
constructor(x, y) { super(x, y, 34, 50); this.kind = 'skeleton'; this.hp = 4; this.speed = 70 + Math.random() * 30; this.shootCooldown = 0; }
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { TILE } from '../core/constants.js';
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
// Функция для расчёта урона с учётом брони
|
||||
function calculateDamage(baseDamage) {
|
||||
// Броня снижает урон пропорционально
|
||||
// armor: 0 = без брони (100% урона)
|
||||
// armor: 0.5 = железная броня (50% урона)
|
||||
const reduction = state.player.armor;
|
||||
const actualDamage = baseDamage * (1 - reduction);
|
||||
console.log('[DAMAGE] Base:', baseDamage, '- Armor:', reduction, '- Actual:', actualDamage.toFixed(1));
|
||||
return actualDamage;
|
||||
}
|
||||
|
||||
// Инициализация игрока
|
||||
function initPlayer() {
|
||||
state.player = {
|
||||
x: 6 * TILE,
|
||||
y: 0 * TILE,
|
||||
w: 34,
|
||||
h: 34,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
grounded: false,
|
||||
inWater: false,
|
||||
headInWater: false,
|
||||
hp: 100,
|
||||
hunger: 100,
|
||||
o2: 100,
|
||||
invuln: 0,
|
||||
fallStartY: 0,
|
||||
lastStepTime: 0,
|
||||
sleeping: false,
|
||||
inBoat: false,
|
||||
armor: 0, // Текущий уровень защиты (0 = без брони, 0.5 = железная броня)
|
||||
equippedArmor: null // Тип надетой брони
|
||||
};
|
||||
|
||||
// Сохраняем начальную позицию для возрождения
|
||||
state.spawnPoint = { x: 6 * TILE, y: 0 * TILE };
|
||||
}
|
||||
|
||||
export { calculateDamage, initPlayer };
|
||||
|
|
@ -0,0 +1,758 @@
|
|||
// ==================== ГЛАВНЫЙ ИГРОВОЙ ЦИКЛ ====================
|
||||
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 { updateWaterFlag } from '../physics/water-detect.js';
|
||||
import { resolveY, resolveX } from '../physics/collision.js';
|
||||
import { calculateDamage } from '../entities/player.js';
|
||||
import { isNight } from '../entities/mob-ai.js';
|
||||
import { tickFurnaces } from '../ui/furnace.js';
|
||||
import { renderFurnaceUI } from '../ui/furnace.js';
|
||||
import { updateWeather, updateRain, drawRain } from '../world/weather.js';
|
||||
import { ensureGenAroundCamera, surfaceGyAt, genColumn } from '../world/generation.js';
|
||||
import { Zombie, Creeper, Skeleton, Pig, Chicken } from '../entities/mobs.js';
|
||||
import { mobAI } from '../entities/mob-ai.js';
|
||||
import { explodeAt } from '../world/tnt.js';
|
||||
import { sendPlayerPosition } from '../multiplayer/socket-helpers.js';
|
||||
import { renderMinimap } from '../ui/minimap.js';
|
||||
import { rebuildHotbar } from '../ui/hotbar.js';
|
||||
import { mode } from '../game/modes.js';
|
||||
import { mouse } from '../input/mouse-handler.js';
|
||||
import { drawFire } from '../render/draw-fire.js';
|
||||
|
||||
// ==================== LOOP ====================
|
||||
let last = performance.now();
|
||||
let prevJump = false;
|
||||
// При возврате на вкладку — сбрасываем last чтобы не было скачка dt
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) last = performance.now();
|
||||
});
|
||||
|
||||
|
||||
|
||||
export function loop(now) {
|
||||
const rawDt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
// Sub-stepping: разбиваем физику на шаги ≤16ms чтобы не проваливаться сквозь блоки
|
||||
const PHYSICS_STEP = 0.016;
|
||||
const steps = Math.max(1, Math.ceil(rawDt / PHYSICS_STEP));
|
||||
const dt = rawDt / steps;
|
||||
|
||||
const player = state.player;
|
||||
const inv = state.inv;
|
||||
const inp = state.inp;
|
||||
const clouds = state.clouds;
|
||||
const mobs = state.mobs;
|
||||
const projectiles = state.projectiles;
|
||||
const parts = state.parts;
|
||||
const boat = state.boat;
|
||||
|
||||
const jumpPressed = inp.j && !prevJump;
|
||||
prevJump = inp.j;
|
||||
|
||||
// Ускорение времени во время сна
|
||||
if (player.sleeping && isNight()) {
|
||||
state.worldTime += dt * 8 / state.DAY_LEN; // В 8 раз быстрее
|
||||
// Восстанавливаем здоровье во время сна
|
||||
player.hp = Math.min(100, player.hp + dt * 20);
|
||||
// Автоматическое пробуждение когда наступает день
|
||||
if (!isNight()) {
|
||||
player.sleeping = false;
|
||||
}
|
||||
} else {
|
||||
state.worldTime += dt / state.DAY_LEN;
|
||||
}
|
||||
if (state.worldTime >= 1) state.worldTime -= 1;
|
||||
|
||||
// камера следует за игроком по X/Y
|
||||
state.camX = Math.floor((player.x + player.w / 2) - state.W / 2);
|
||||
state.camY = Math.floor((player.y + player.h / 2) - state.H / 2);
|
||||
|
||||
ensureGenAroundCamera();
|
||||
|
||||
// clouds parallax
|
||||
for (const c of clouds) {
|
||||
c.x -= c.s * dt;
|
||||
if (c.x + c.w < state.camX - 400) c.x = state.camX + state.W + Math.random() * 700;
|
||||
}
|
||||
|
||||
// player
|
||||
updateWaterFlag(player);
|
||||
|
||||
// кислород/утопление: тратим O2 только если голова под водой, иначе восстанавливаем
|
||||
if (player.headInWater) {
|
||||
player.o2 = Math.max(0, player.o2 - 6 * dt); // замедлил в 3.7 раза
|
||||
if (player.o2 === 0) {
|
||||
const damage = calculateDamage(4 * dt);
|
||||
player.hp -= damage;
|
||||
}
|
||||
} else {
|
||||
player.o2 = Math.min(100, player.o2 + 10 * dt); // замедлил восстановление в 4 раза
|
||||
}
|
||||
|
||||
// голод убывает, но HP не отнимает (как просили)
|
||||
player.hunger = Math.max(0, player.hunger - dt * 0.2); // замедлил в 4 раза
|
||||
|
||||
// Игрок не может двигаться во время сна
|
||||
if (player.sleeping) {
|
||||
player.vx = 0;
|
||||
player.vy = 0;
|
||||
} else {
|
||||
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
|
||||
if (dir) player.vx = dir * state.MOVE;
|
||||
else player.vx *= 0.82;
|
||||
}
|
||||
|
||||
// Звук шагов при движении по земле
|
||||
if (player.grounded && !player.inWater && Math.abs(player.vx) > 50) {
|
||||
const stepInterval = 0.35; // Интервал между шагами в секундах
|
||||
if (now / 1000 - player.lastStepTime > stepInterval) {
|
||||
playSound('step');
|
||||
player.lastStepTime = now / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// прыжок/плавание (новая логика)
|
||||
if (player.inBoat) {
|
||||
// Игрок в лодке - лодка следует за игроком
|
||||
const dir = (inp.r ? 1 : 0) - (inp.l ? 1 : 0);
|
||||
if (dir) boat.vx = dir * state.MOVE;
|
||||
else boat.vx *= 0.95;
|
||||
|
||||
// Лодка плавает на воде
|
||||
boat.vy = 0;
|
||||
|
||||
// Игрок следует за лодкой (сидит внутри неё)
|
||||
player.x = boat.x + 2; // Игрок по центру лодки
|
||||
player.y = boat.y - 4; // Игрок выше лодки (сидит внутри)
|
||||
player.vx = boat.vx;
|
||||
player.vy = boat.vy;
|
||||
player.grounded = true;
|
||||
player.inWater = false; // Игрок не в воде когда в лодке
|
||||
|
||||
// Прыжок из лодки (высадка)
|
||||
if (jumpPressed) {
|
||||
// Возвращаем лодку в инвентарь
|
||||
inv.boat = (inv.boat || 0) + 1;
|
||||
|
||||
player.inBoat = false;
|
||||
boat.active = false;
|
||||
player.y += state.TILE; // Прыгаем из лодки
|
||||
player.vy = -state.JUMP * 0.5;
|
||||
playSound('splash');
|
||||
}
|
||||
|
||||
} else if (player.inWater) {
|
||||
// сопротивление в воде
|
||||
player.vx *= 0.90;
|
||||
player.vy *= 0.92;
|
||||
|
||||
// Если не нажимаем прыжок - тонем (гравитация в воде)
|
||||
if (!jumpPressed && !inp.j) {
|
||||
// Применяем гравитацию в воде - игрок тонет
|
||||
player.vy += state.GRAV_WATER * dt;
|
||||
} else {
|
||||
// Если нажимаем прыжок - поднимаемся на поверхность
|
||||
if (jumpPressed) {
|
||||
player.vy = Math.min(player.vy, -520); // рывок вверх
|
||||
} else if (inp.j) {
|
||||
// если держим — мягкое всплытие
|
||||
player.vy = Math.min(player.vy, -260);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// обычный прыжок (только по нажатию)
|
||||
if (jumpPressed && player.grounded && !player.sleeping) {
|
||||
player.vy = -state.JUMP;
|
||||
player.grounded = false;
|
||||
player.fallStartY = player.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Гравитация применяется только вне воды и вне лодки
|
||||
if (!player.inWater && !player.inBoat) {
|
||||
player.vy += state.GRAV * dt;
|
||||
}
|
||||
|
||||
// Обновляем позицию лодки
|
||||
if (boat.active) {
|
||||
boat.x += boat.vx * dt;
|
||||
boat.y += boat.vy * dt;
|
||||
|
||||
// Лодка не выходит за пределы воды
|
||||
const boatGX = Math.floor(boat.x / state.TILE);
|
||||
const boatGY = Math.floor(boat.y / state.TILE);
|
||||
const below = getBlock(boatGX, boatGY + 1);
|
||||
|
||||
if (!below || below.t !== 'water') {
|
||||
// Если лодка вышла из воды - выкидываем игрока
|
||||
inv.boat = (inv.boat || 0) + 1;
|
||||
player.inBoat = false;
|
||||
boat.active = false;
|
||||
player.y += state.TILE;
|
||||
player.vy = -200;
|
||||
playSound('splash');
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, не доплыл ли игрок из лодки
|
||||
if (player.inBoat && !boat.active) {
|
||||
inv.boat = (inv.boat || 0) + 1;
|
||||
player.inBoat = false;
|
||||
player.y += state.TILE;
|
||||
player.vy = -200;
|
||||
playSound('splash');
|
||||
}
|
||||
|
||||
// Sub-stepped physics: применяем движение мелкими шагами
|
||||
for (let step = 0; step < steps; step++) {
|
||||
player.y += player.vy * dt;
|
||||
resolveY(player);
|
||||
player.x += player.vx * dt;
|
||||
resolveX(player);
|
||||
}
|
||||
|
||||
// Отправляем позицию на сервер (мультиплеер)
|
||||
sendPlayerPosition();
|
||||
|
||||
// Обновляем физику воды
|
||||
updateWaterPhysics(dt);
|
||||
|
||||
// Погода и дождь
|
||||
updateWeather(dt);
|
||||
updateRain(dt);
|
||||
|
||||
player.invuln = Math.max(0, player.invuln - dt);
|
||||
|
||||
// Voice position update
|
||||
state.voicePosT += dt;
|
||||
if (state.voicePosT > 0.5 && state.voiceSocket && state.voiceSocket.connected) {
|
||||
state.voicePosT = 0;
|
||||
state.voiceSocket.emit('voice_pos', { x: player.x, y: player.y });
|
||||
}
|
||||
|
||||
// Furnace tick
|
||||
tickFurnaces(dt);
|
||||
|
||||
// Обновляем UI печи если открыта
|
||||
if (state.currentFurnaceKey && Math.random() < 0.1) {
|
||||
renderFurnaceUI();
|
||||
}
|
||||
|
||||
// Projectile tick (стрелы)
|
||||
for (let i = projectiles.length - 1; i >= 0; i--) {
|
||||
const p = projectiles[i];
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vy += 400 * dt; // гравитация
|
||||
p.life -= dt;
|
||||
|
||||
// Столкновение с блоком
|
||||
const gx = Math.floor(p.x / state.TILE);
|
||||
const gy = Math.floor(p.y / state.TILE);
|
||||
const blk = getBlock(gx, gy);
|
||||
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid) {
|
||||
// Врезался в стену
|
||||
if (p.owner === 'player' && Math.random() < 0.5) inv.arrow++; // подбираем ~50%
|
||||
projectiles.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Столкновение с сущностью
|
||||
if (p.owner === 'mob') {
|
||||
// Попал в игрока
|
||||
if (p.x > player.x && p.x < player.x + player.w && p.y > player.y && p.y < player.y + player.h) {
|
||||
if (player.invuln <= 0) {
|
||||
player.hp -= calculateDamage(p.dmg);
|
||||
player.invuln = 0.4;
|
||||
player.vx += p.vx * 0.3;
|
||||
player.vy -= 150;
|
||||
playSound('hit1');
|
||||
}
|
||||
projectiles.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Попал в моба — server mobs first in multiplayer
|
||||
let hitMob = false;
|
||||
if (state.isMultiplayer) {
|
||||
for (const [id, sm] of state.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) {
|
||||
state.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) {
|
||||
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 (p.life <= 0) projectiles.splice(i, 1);
|
||||
}
|
||||
|
||||
// TNT tick
|
||||
for (const key of Array.from(state.activeTNT)) {
|
||||
const b = state.grid.get(key);
|
||||
if (!b || b.dead) { state.activeTNT.delete(key); continue; }
|
||||
b.fuse -= dt;
|
||||
if (b.fuse <= 0) {
|
||||
explodeAt(b.gx, b.gy);
|
||||
}
|
||||
}
|
||||
|
||||
// mobs spawn (с обеих сторон камеры) — только в одиночном режиме
|
||||
state.spawnT += dt;
|
||||
if (!state.isMultiplayer && state.spawnT > 1.8 && mobs.length < 30) {
|
||||
state.spawnT = 0;
|
||||
|
||||
// Выбираем сторону спавна (левая или правая)
|
||||
const spawnLeft = Math.random() < 0.5;
|
||||
const gx = spawnLeft
|
||||
? Math.floor((state.camX - 200) / state.TILE)
|
||||
: Math.floor((state.camX + state.W + 200) / state.TILE);
|
||||
|
||||
genColumn(gx);
|
||||
const sgy = surfaceGyAt(gx);
|
||||
const wx = gx * state.TILE + 4;
|
||||
const wy = (sgy - 2) * state.TILE;
|
||||
|
||||
// не спавнить в воде
|
||||
const top = getBlock(gx, sgy);
|
||||
if (top && top.t === 'water') {
|
||||
// skip
|
||||
} else {
|
||||
const night = isNight();
|
||||
if (night) {
|
||||
// Ночью спавним враждебных мобов (максимум 12 хостайл)
|
||||
const hostileCount = mobs.filter(m => m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton').length;
|
||||
if (hostileCount < 12) {
|
||||
const rand = Math.random();
|
||||
if (rand < 0.35) {
|
||||
mobs.push(new Zombie(wx, wy));
|
||||
} else if (rand < 0.55) {
|
||||
mobs.push(new Creeper(wx, wy));
|
||||
} else {
|
||||
mobs.push(new Skeleton(wx, wy));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Животные спавнятся и днём и ночью (с лимитом)
|
||||
const animalCount = mobs.filter(m => m.kind === 'pig' || m.kind === 'chicken').length;
|
||||
if (animalCount < 8) {
|
||||
mobs.push(Math.random() < 0.5 ? new Pig(wx, wy) : new Chicken(wx, wy));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mobs update — только локальные (singleplayer)
|
||||
if (!state.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);
|
||||
}
|
||||
}
|
||||
|
||||
// particles
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const p = parts[i];
|
||||
p.t -= dt;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vy += state.GRAV * dt;
|
||||
if (p.t <= 0) parts.splice(i, 1);
|
||||
}
|
||||
|
||||
// death
|
||||
if (player.hp <= 0) {
|
||||
state.deathEl.style.display = 'flex';
|
||||
} else if (state.deathEl.style.display === 'flex') {
|
||||
// Если HP > 0 но экран смерти всё ещё показан - скрываем его
|
||||
state.deathEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== RENDER ====================
|
||||
const ctx = state.ctx;
|
||||
const W = state.W;
|
||||
const H = state.H;
|
||||
const camX = state.camX;
|
||||
const camY = state.camY;
|
||||
const TILE = state.TILE;
|
||||
const tex = state.tex;
|
||||
const night = isNight();
|
||||
|
||||
// sky
|
||||
ctx.fillStyle = night ? '#070816' : (state.isRaining ? '#6B7B8D' : '#87CEEB');
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// clouds (parallax x/y)
|
||||
ctx.save();
|
||||
ctx.translate(-camX * 0.5, -camY * 0.15);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.65)';
|
||||
for (const c of clouds) {
|
||||
ctx.fillRect(c.x, c.y, c.w, 26);
|
||||
ctx.fillRect(c.x + 20, c.y - 10, c.w * 0.6, 22);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// world
|
||||
ctx.save();
|
||||
ctx.translate(-camX, -camY);
|
||||
|
||||
const minGX = Math.floor(camX / TILE) - 2;
|
||||
const maxGX = Math.floor((camX + W) / TILE) + 2;
|
||||
const minGY = Math.floor(camY / TILE) - 6;
|
||||
const maxGY = Math.floor((camY + H) / TILE) + 6;
|
||||
const blocks = state.blocks;
|
||||
|
||||
// draw blocks (по массиву, но фильтруем диапазоном)
|
||||
for (const b of blocks) {
|
||||
if (b.dead) continue;
|
||||
if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
|
||||
|
||||
const def = BLOCKS[b.t];
|
||||
if (def.alpha) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = def.alpha;
|
||||
ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
|
||||
}
|
||||
|
||||
// TNT мигает, если активирован
|
||||
if (b.t === 'tnt' && b.active && Math.sin(now / 60) > 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
ctx.fillRect(b.gx * TILE, b.gy * TILE, TILE, TILE);
|
||||
}
|
||||
|
||||
// огонь костра
|
||||
if (b.t === 'campfire') {
|
||||
drawFire(ctx, b.gx * TILE, b.gy * TILE, now);
|
||||
}
|
||||
// Печь — огонь когда обжигает
|
||||
if (b.t === 'furnace' && state.activeFurnaces.has(`${b.gx},${b.gy}`)) {
|
||||
drawFire(ctx, b.gx * TILE + 8, b.gy * TILE + 5, now);
|
||||
}
|
||||
}
|
||||
|
||||
// mobs
|
||||
const allMobsRender = state.isMultiplayer ? Array.from(state.serverMobs.values()) : mobs;
|
||||
for (const m of allMobsRender) {
|
||||
if (m.kind === 'zombie') {
|
||||
ctx.fillStyle = '#2ecc71';
|
||||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||||
ctx.fillStyle = '#c0392b';
|
||||
ctx.fillRect(m.x + 6, m.y + 12, 6, 6);
|
||||
ctx.fillRect(m.x + 22, m.y + 12, 6, 6);
|
||||
} else if (m.kind === 'pig') {
|
||||
ctx.fillStyle = '#ffb6c1';
|
||||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(m.x + 22, m.y + 5, 3, 3);
|
||||
ctx.fillStyle = '#ff69b4';
|
||||
ctx.fillRect(m.x + 28, m.y + 12, 6, 6);
|
||||
} else if (m.kind === 'chicken') {
|
||||
// chicken
|
||||
ctx.fillStyle = '#ecf0f1';
|
||||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||||
ctx.fillStyle = '#f39c12';
|
||||
ctx.fillRect(m.x + 18, m.y + 10, 6, 4);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(m.x + 8, m.y + 6, 3, 3);
|
||||
} else if (m.kind === 'creeper') {
|
||||
// creeper
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
ctx.fillRect(m.x, m.y, m.w, m.h);
|
||||
// Глаза
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(m.x + 8, m.y + 8, 4, 4);
|
||||
ctx.fillRect(m.x + 22, m.y + 8, 4, 4);
|
||||
// Рот
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(m.x + 12, m.y + 20, 10, 4);
|
||||
// Ноги
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
ctx.fillRect(m.x + 4, m.y + 30, 6, 20);
|
||||
ctx.fillRect(m.x + 24, m.y + 30, 6, 20);
|
||||
} else if (m.kind === 'skeleton') {
|
||||
// skeleton - детализированный
|
||||
ctx.fillStyle = '#ECEFF1';
|
||||
ctx.fillRect(m.x + 10, m.y + 20, 14, 12);
|
||||
ctx.fillRect(m.x + 8, m.y + 0, 18, 18);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(m.x + 10, m.y + 6, 4, 4);
|
||||
ctx.fillRect(m.x + 20, m.y + 6, 4, 4);
|
||||
ctx.fillRect(m.x + 15, m.y + 12, 4, 2);
|
||||
ctx.fillStyle = '#ECEFF1';
|
||||
ctx.fillRect(m.x + 2, m.y + 20, 6, 14);
|
||||
ctx.fillRect(m.x + 26, m.y + 20, 6, 14);
|
||||
ctx.fillRect(m.x + 10, m.y + 32, 6, 18);
|
||||
ctx.fillRect(m.x + 18, m.y + 32, 6, 18);
|
||||
// Лук в руке
|
||||
ctx.save();
|
||||
ctx.translate(m.x + 30, m.y + 22);
|
||||
ctx.strokeStyle = '#8B4513';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 8, -Math.PI * 0.7, Math.PI * 0.7);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#ccc';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(8 * Math.cos(-Math.PI * 0.7), 8 * Math.sin(-Math.PI * 0.7));
|
||||
ctx.lineTo(8 * Math.cos(Math.PI * 0.7), 8 * Math.sin(Math.PI * 0.7));
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// boat (рисуем первой, чтобы игрок был внутри неё)
|
||||
if (boat.active) {
|
||||
ctx.drawImage(tex['boat'], boat.x - (TILE - boat.w) / 2, boat.y - (TILE - boat.h) / 2, TILE, TILE);
|
||||
}
|
||||
|
||||
// other players (multiplayer)
|
||||
for (const [socketId, p] of state.otherPlayers) {
|
||||
if (state.heroImg.complete) {
|
||||
ctx.drawImage(state.heroImg, p.x - (TILE - player.w) / 2, p.y - (TILE - player.h) / 2, TILE, TILE);
|
||||
} else {
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(p.x, p.y, 34, 34);
|
||||
}
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '12px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(p.name, p.x + 17, p.y - 8);
|
||||
}
|
||||
|
||||
// player
|
||||
if (state.heroImg.complete) {
|
||||
ctx.drawImage(state.heroImg, player.x - (TILE - player.w) / 2, player.y - (TILE - player.h) / 2, TILE, TILE);
|
||||
} else {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(player.x, player.y, player.w, player.h);
|
||||
}
|
||||
|
||||
// projectiles (стрелы)
|
||||
for (const p of projectiles) {
|
||||
const angle = Math.atan2(p.vy, p.vx);
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillStyle = p.owner === 'mob' ? '#c0392b' : '#f1c40f';
|
||||
ctx.fillRect(-12, -1.5, 24, 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(12, -4);
|
||||
ctx.lineTo(16, 0);
|
||||
ctx.lineTo(12, 4);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.fillRect(-12, -3, 4, 2);
|
||||
ctx.fillRect(-12, 1, 4, 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// particles
|
||||
for (const p of parts) {
|
||||
ctx.fillStyle = p.c;
|
||||
ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
|
||||
}
|
||||
|
||||
// Стрелы скелета
|
||||
for (const m of mobs) {
|
||||
if (m.kind === 'skeleton' && m.shootCooldown > 0.5) {
|
||||
const arrowX = m.x + m.w / 2;
|
||||
const arrowY = m.y + 15;
|
||||
const targetX = player.x + player.w / 2;
|
||||
const targetY = player.y + player.h / 2;
|
||||
const angle = Math.atan2(targetY - arrowY, targetX - arrowX);
|
||||
const dx = targetX - arrowX;
|
||||
const dy = targetY - arrowY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(arrowX, arrowY);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillStyle = '#ECEFF1';
|
||||
ctx.fillRect(0, -1, 16, 2);
|
||||
ctx.restore();
|
||||
|
||||
if (dist < 150 && player.invuln <= 0) {
|
||||
player.hp -= 8;
|
||||
player.invuln = 0.5;
|
||||
player.vx += Math.cos(angle) * 300;
|
||||
player.vy -= 200;
|
||||
playSound('hit1');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build ghost
|
||||
if (mode() === 'build' && mouse.x !== null && !state.craftOpen && player.hp > 0) {
|
||||
const wx = mouse.x + camX;
|
||||
const wy = mouse.y + camY;
|
||||
const ggx = Math.floor(wx / TILE);
|
||||
const ggy = Math.floor(wy / TILE);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(ggx * TILE, ggy * TILE, TILE, TILE);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
|
||||
if (night) {
|
||||
const lightC = state.lightC;
|
||||
const lightCtx = state.lightCtx;
|
||||
|
||||
lightC.width = W * state.dpr;
|
||||
lightC.height = H * state.dpr;
|
||||
lightCtx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
||||
lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
|
||||
lightCtx.fillRect(0, 0, W, H);
|
||||
|
||||
lightCtx.globalCompositeOperation = 'destination-out';
|
||||
|
||||
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 r = radius * flick;
|
||||
const steps2 = 12;
|
||||
const dists = new Float32Array(steps2);
|
||||
for (let i = 0; i < steps2; i++) {
|
||||
const ang = (i / steps2) * Math.PI * 2;
|
||||
const ddx = Math.cos(ang);
|
||||
const ddy = Math.sin(ang);
|
||||
let maxDist = r;
|
||||
for (let step = TILE * 0.5; step < r; step += TILE * 0.6) {
|
||||
const lgx = Math.floor((sx + ddx * step) / TILE);
|
||||
const lgy = Math.floor((sy + ddy * step) / TILE);
|
||||
const blk = getBlock(lgx, lgy);
|
||||
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) {
|
||||
maxDist = step;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dists[i] = maxDist;
|
||||
}
|
||||
const cx = sx - camX, cy = sy - camY;
|
||||
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.5, 'rgba(255,255,255,0.65)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
lightCtx.fillStyle = grad;
|
||||
lightCtx.beginPath();
|
||||
for (let i = 0; i <= steps2; i++) {
|
||||
const idx = i % steps2;
|
||||
const ang = (idx / steps2) * Math.PI * 2;
|
||||
const px = cx + Math.cos(ang) * dists[idx];
|
||||
const py = cy + Math.sin(ang) * dists[idx];
|
||||
if (i === 0) lightCtx.moveTo(px, py);
|
||||
else lightCtx.lineTo(px, py);
|
||||
}
|
||||
lightCtx.closePath();
|
||||
lightCtx.fill();
|
||||
}
|
||||
|
||||
for (const b of blocks) {
|
||||
if (b.dead) continue;
|
||||
if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
|
||||
const def = BLOCKS[b.t];
|
||||
if (def.lightRadius) {
|
||||
castLight(b.gx * TILE + TILE / 2, b.gy * TILE + TILE / 2, def.lightRadius);
|
||||
}
|
||||
}
|
||||
|
||||
lightCtx.globalCompositeOperation = 'source-over';
|
||||
ctx.drawImage(lightC, 0, 0, W, H);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (const b of blocks) {
|
||||
if (b.dead) continue;
|
||||
if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
|
||||
const def = BLOCKS[b.t];
|
||||
if (def.lightRadius) {
|
||||
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.6 * flick;
|
||||
const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
|
||||
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();
|
||||
ctx.arc(wx, wy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Дождь (после ночного оверлея)
|
||||
drawRain();
|
||||
if (Math.random() < 0.25) {
|
||||
state.hpEl.textContent = Math.max(0, Math.ceil(player.hp));
|
||||
state.foodEl.textContent = Math.ceil(player.hunger);
|
||||
document.getElementById('o2').textContent = Math.ceil(player.o2);
|
||||
state.sxEl.textContent = Math.floor(player.x / TILE);
|
||||
state.syEl.textContent = Math.floor(player.y / TILE);
|
||||
state.todEl.textContent = night ? 'Ночь' : 'День';
|
||||
state.worldIdEl.textContent = state.worldId;
|
||||
if (state.isMultiplayer) {
|
||||
document.getElementById('multiplayerStatus').style.display = 'flex';
|
||||
state.playerCountEl.textContent = state.otherPlayers.size + 1;
|
||||
} else {
|
||||
document.getElementById('multiplayerStatus').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Индикатор сна
|
||||
if (player.sleeping) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 32px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('💤 Спим...', W / 2, H / 2);
|
||||
ctx.font = '18px system-ui';
|
||||
ctx.fillText('Нажмите на кровать чтобы проснуться', W / 2, H / 2 + 40);
|
||||
}
|
||||
|
||||
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
|
||||
if (state.minimapOpen && Math.random() < 0.25) {
|
||||
renderMinimap();
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// ==================== РЕЖИМЫ ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
|
||||
const MODES = [{ id: 'mine', icon: '⛏️' }, { id: 'build', icon: '🧱' }];
|
||||
|
||||
export { MODES };
|
||||
|
||||
export function mode() {
|
||||
return MODES[state.modeIdx].id;
|
||||
}
|
||||
|
||||
export function initModes() {
|
||||
const modeBtn = document.getElementById('modeBtn');
|
||||
modeBtn.onclick = () => {
|
||||
playSound('click'); // Звук клика по кнопке режима
|
||||
state.modeIdx = (state.modeIdx + 1) % MODES.length;
|
||||
modeBtn.textContent = MODES[state.modeIdx].icon;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
// ==================== СИСТЕМА СОХРАНЕНИЯ ИГРЫ ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { getBlock, setBlock, removeBlock } from '../world/world-storage.js';
|
||||
import { regenerateVisibleChunks } from '../world/generation.js';
|
||||
import { rebuildHotbar } from '../ui/hotbar.js';
|
||||
|
||||
const SAVE_KEY = state.SAVE_KEY;
|
||||
let db = null; // Оставляем для совместимости, но не используем
|
||||
|
||||
// Инициализация (localStorage + in-memory fallback)
|
||||
export function initDB() {
|
||||
return new Promise((resolve) => {
|
||||
console.log('Используем localStorage для сохранений (sandbox режим)');
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveGame() {
|
||||
const saveData = {
|
||||
version: 2,
|
||||
worldSeed: state.worldSeed,
|
||||
player: {
|
||||
x: state.player.x,
|
||||
y: state.player.y,
|
||||
hp: state.player.hp,
|
||||
hunger: state.player.hunger,
|
||||
o2: state.player.o2
|
||||
},
|
||||
inventory: state.inv,
|
||||
time: state.worldTime,
|
||||
isNight: state.isNightTime,
|
||||
// Сохраняем только изменения
|
||||
placedBlocks: state.placedBlocks.slice(),
|
||||
removedBlocks: state.removedBlocks.slice()
|
||||
};
|
||||
|
||||
const saveSize = JSON.stringify(saveData).length;
|
||||
console.log('Сохранение: player HP:', state.player.hp, 'hunger:', state.player.hunger, 'o2:', state.player.o2);
|
||||
|
||||
// Пробуем сохранить в localStorage (основной метод для персистентности)
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(saveData));
|
||||
console.log(`Игра сохранена в localStorage (размер: ${saveSize} байт)`);
|
||||
} catch (e) {
|
||||
console.warn('Ошибка сохранения в localStorage, используем только in-memory:', e);
|
||||
|
||||
// Если localStorage недоступен, используем in-memory fallback
|
||||
state.inMemorySave = saveData;
|
||||
console.log(`Игра сохранена в памяти (размер: ${saveSize} байт)`);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadGame() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Пробуем localStorage
|
||||
try {
|
||||
const localSave = localStorage.getItem(SAVE_KEY);
|
||||
if (localSave) {
|
||||
const parsed = JSON.parse(localSave);
|
||||
console.log('Загружено из localStorage, player HP:', parsed.player?.hp);
|
||||
resolve(parsed);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Ошибка доступа к localStorage:', e);
|
||||
}
|
||||
|
||||
// Если localStorage недоступен, используем in-memory сохранение
|
||||
if (state.inMemorySave) {
|
||||
console.log('Загружено из in-memory сохранения, player HP:', state.inMemorySave.player?.hp);
|
||||
resolve(state.inMemorySave);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Сохранение не найдено');
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
// Миграция с версии 1 на версию 2
|
||||
export function migrateV1toV2(saveData) {
|
||||
console.log('Миграция сохранения с версии 1 на версию 2...');
|
||||
|
||||
// Сохраняем seed из текущей игры (так как v1 его не хранил)
|
||||
saveData.worldSeed = state.worldSeed;
|
||||
|
||||
// Инициализируем массивы изменений
|
||||
saveData.placedBlocks = [];
|
||||
saveData.removedBlocks = [];
|
||||
|
||||
// Для v1 нам нужно сравнить сгенерированные блоки с тем, что должно быть по seed
|
||||
// Это сложно сделать без полной перегенерации, поэтому для v1 сохраняем только seed
|
||||
// и при загрузке просто перегенерируем мир
|
||||
|
||||
// Удаляем старые данные
|
||||
delete saveData.generatedBlocks;
|
||||
|
||||
saveData.version = 2;
|
||||
console.log('Миграция завершена');
|
||||
}
|
||||
|
||||
export async function applySave(saveData) {
|
||||
if (!saveData) return;
|
||||
|
||||
console.log('=== applySave START ===');
|
||||
console.log('player HP before applySave:', state.player.hp);
|
||||
console.log('saveData.player.hp:', saveData.player?.hp);
|
||||
|
||||
// Миграция версий
|
||||
if (saveData.version === 1) {
|
||||
migrateV1toV2(saveData);
|
||||
}
|
||||
|
||||
// Восстанавливаем seed
|
||||
if (saveData.worldSeed !== undefined) {
|
||||
state.worldSeed = saveData.worldSeed;
|
||||
}
|
||||
|
||||
// Восстанавливаем игрока
|
||||
if (saveData.player) {
|
||||
state.player.x = saveData.player.x;
|
||||
state.player.y = saveData.player.y;
|
||||
state.player.hunger = saveData.player.hunger;
|
||||
state.player.o2 = saveData.player.o2;
|
||||
|
||||
// Обновляем spawnPoint на позицию из сохранения
|
||||
state.spawnPoint.x = state.player.x;
|
||||
state.spawnPoint.y = state.player.y;
|
||||
|
||||
// Проверяем HP из сохранения - если <= 0, устанавливаем 100
|
||||
const savedHP = saveData.player.hp;
|
||||
console.log('Saved HP from file:', savedHP);
|
||||
if (savedHP <= 0) {
|
||||
console.log('WARNING: Saved HP is <= 0, setting to 100!');
|
||||
state.player.hp = 100;
|
||||
} else {
|
||||
state.player.hp = savedHP;
|
||||
}
|
||||
console.log('player HP after restore:', state.player.hp);
|
||||
console.log('spawnPoint обновлён из сохранения: x=', state.spawnPoint.x, 'y=', state.spawnPoint.y);
|
||||
} else {
|
||||
console.log('No player data in save, setting default HP: 100');
|
||||
state.player.hp = 100;
|
||||
}
|
||||
|
||||
console.log('=== applySave END ===');
|
||||
|
||||
// Восстанавливаем инвентарь
|
||||
if (saveData.inventory) {
|
||||
for (const key in saveData.inventory) {
|
||||
state.inv[key] = saveData.inventory[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Восстанавливаем время
|
||||
if (saveData.time !== undefined) {
|
||||
state.worldTime = saveData.time;
|
||||
}
|
||||
|
||||
// Восстанавливаем день/ночь
|
||||
if (saveData.isNight !== undefined) {
|
||||
state.isNightTime = saveData.isNight;
|
||||
}
|
||||
|
||||
// Перегенерируем мир по seed
|
||||
regenerateVisibleChunks();
|
||||
|
||||
// Применяем изменения (только для v2)
|
||||
if (saveData.version === 2) {
|
||||
// Применяем блоки, установленные игроком
|
||||
for (const block of saveData.placedBlocks) {
|
||||
setBlock(block.gx, block.gy, block.t, true);
|
||||
}
|
||||
|
||||
// Применяем удалённые блоки
|
||||
for (const block of saveData.removedBlocks) {
|
||||
removeBlock(block.gx, block.gy);
|
||||
}
|
||||
|
||||
// Восстанавливаем массивы изменений
|
||||
state.placedBlocks = saveData.placedBlocks || [];
|
||||
state.removedBlocks = saveData.removedBlocks || [];
|
||||
}
|
||||
|
||||
rebuildHotbar();
|
||||
console.log('Игра загружена');
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// ==================== УПРАВЛЕНИЕ ====================
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
export const inp = state.inp;
|
||||
|
||||
export function bindHold(el, key) {
|
||||
const down = (e) => { e.preventDefault(); state.inp[key] = true; };
|
||||
const up = (e) => { e.preventDefault(); state.inp[key] = false; };
|
||||
el.addEventListener('pointerdown', down);
|
||||
el.addEventListener('pointerup', up);
|
||||
el.addEventListener('pointerleave', up);
|
||||
}
|
||||
|
||||
export function initControls() {
|
||||
const leftBtn = document.getElementById('left');
|
||||
const rightBtn = document.getElementById('right');
|
||||
const jumpBtn = document.getElementById('jump');
|
||||
const downBtn = document.getElementById('down');
|
||||
|
||||
if (leftBtn) bindHold(leftBtn, 'l');
|
||||
if (rightBtn) bindHold(rightBtn, 'r');
|
||||
if (jumpBtn) bindHold(jumpBtn, 'j');
|
||||
if (downBtn) bindHold(downBtn, 's');
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = true;
|
||||
if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = true;
|
||||
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = true;
|
||||
if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = true;
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'KeyA' || e.code === 'ArrowLeft') state.inp.l = false;
|
||||
if (e.code === 'KeyD' || e.code === 'ArrowRight') state.inp.r = false;
|
||||
if (e.code === 'Space' || e.code === 'KeyW' || e.code === 'ArrowUp') state.inp.j = false;
|
||||
if (e.code === 'KeyS' || e.code === 'ArrowDown') state.inp.s = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
// ==================== ВЗАИМОДЕЙСТВИЕ МЫШЬ/ТАП ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { ITEMS } from '../data/items.js';
|
||||
import { TOOLS } from '../data/tools.js';
|
||||
import { getBlock, setBlock, removeBlock } from '../world/world-storage.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { rebuildHotbar } from '../ui/hotbar.js';
|
||||
import { mode } from '../game/modes.js';
|
||||
import { openFurnaceUI } from '../ui/furnace.js';
|
||||
import { saveGame } from '../game/save.js';
|
||||
import { useTool } from '../data/tools.js';
|
||||
import { activateTNT } from '../world/tnt.js';
|
||||
import { sendBlockChange } from '../multiplayer/socket-helpers.js';
|
||||
import { isNight } from '../entities/mob-ai.js';
|
||||
|
||||
export const mouse = { x: null, y: null };
|
||||
|
||||
export function initMouseHandlers() {
|
||||
const canvas = state.canvas;
|
||||
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
mouse.x = e.clientX - r.left;
|
||||
mouse.y = e.clientY - r.top;
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
if (state.craftOpen) return;
|
||||
if (state.player.hp <= 0) return;
|
||||
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const sx = e.clientX - r.left;
|
||||
const sy = e.clientY - r.top;
|
||||
|
||||
const wx = sx + state.camX;
|
||||
const wy = sy + state.camY;
|
||||
|
||||
const gx = Math.floor(wx / state.TILE);
|
||||
const gy = Math.floor(wy / state.TILE);
|
||||
|
||||
// Пробуждение: клик по любой кровати когда спишь
|
||||
const b = getBlock(gx, gy);
|
||||
if (state.player.sleeping && b && b.t === 'bed') {
|
||||
state.player.sleeping = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.player.sleeping) return; // Нельзя взаимодействовать во время сна
|
||||
|
||||
// Клик по печи — открываем панель обжига
|
||||
if (b && b.t === 'furnace' && mode() === 'mine') {
|
||||
openFurnaceUI(gx, gy);
|
||||
return;
|
||||
}
|
||||
|
||||
// клик по мобу (в режиме mine)
|
||||
if (mode() === 'mine') {
|
||||
// Check server mobs first (multiplayer)
|
||||
if (state.isMultiplayer) {
|
||||
for (const [id, sm] of state.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 (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
|
||||
}
|
||||
state.socket.emit('mob_hurt', { id: sm.id, dmg });
|
||||
playSound('attack');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Local mobs (singleplayer or if not hit server mob)
|
||||
for (let i = state.mobs.length - 1; i >= 0; i--) {
|
||||
const m = state.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'];
|
||||
for (const st of swordTypes) {
|
||||
if (state.inv[st] > 0) { dmg = TOOLS[st].damage || 3; useTool(st); break; }
|
||||
}
|
||||
m.hp -= dmg;
|
||||
m.vx += (m.x - state.player.x) * 2;
|
||||
m.vy -= 200;
|
||||
playSound('attack');
|
||||
if (m.hp <= 0) {
|
||||
if (m.kind === 'chicken') playSound('hurt_chicken');
|
||||
state.inv.meat += (m.kind === 'chicken' ? 1 : 2);
|
||||
if (m.kind === 'skeleton') {
|
||||
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
|
||||
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
|
||||
}
|
||||
state.mobs.splice(i, 1);
|
||||
rebuildHotbar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Лук — стреляем стрелой
|
||||
if (state.selected === 'bow' && state.inv.bow > 0 && state.inv.arrow > 0) {
|
||||
const aimX = wx - state.player.x - state.player.w / 2;
|
||||
const aimY = wy - state.player.y - state.player.h / 2;
|
||||
const angle = Math.atan2(aimY, aimX);
|
||||
state.projectiles.push({
|
||||
x: state.player.x + state.player.w / 2,
|
||||
y: state.player.y + state.player.h / 3,
|
||||
vx: Math.cos(angle) * 550,
|
||||
vy: Math.sin(angle) * 550,
|
||||
dmg: 10,
|
||||
owner: 'player',
|
||||
life: 4
|
||||
});
|
||||
state.inv.arrow--;
|
||||
useTool('bow');
|
||||
playSound('hit1');
|
||||
rebuildHotbar();
|
||||
return;
|
||||
}
|
||||
|
||||
// еда (предмет)
|
||||
if (ITEMS[state.selected] && state.inv[state.selected] > 0) {
|
||||
const it = ITEMS[state.selected];
|
||||
if (state.player.hp < 100 || state.player.hunger < 100) {
|
||||
playSound('eat1'); // Звук употребления еды
|
||||
state.player.hunger = Math.min(100, state.player.hunger + it.food);
|
||||
state.player.hp = Math.min(100, state.player.hp + 15);
|
||||
state.inv[state.selected]--;
|
||||
rebuildHotbar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// жарка на костре: выбран meat + клик по campfire
|
||||
if (b && b.t === 'campfire' && state.selected === 'meat' && state.inv.meat > 0) {
|
||||
playSound('fire'); // Звук при жарке на костре
|
||||
state.inv.meat--;
|
||||
state.inv.cooked++;
|
||||
rebuildHotbar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Сон на кровати: клик по bed
|
||||
if (b && b.t === 'bed' && isNight()) {
|
||||
state.player.sleeping = true;
|
||||
saveGame(); // Сохраняем при отходе ко сну
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode() === 'mine') {
|
||||
if (!b) return;
|
||||
if (BLOCKS[b.t].fluid || BLOCKS[b.t].decor) return;
|
||||
|
||||
if (b.t === 'tnt') { activateTNT(b, 3.2); return; } // не взрывается сразу
|
||||
|
||||
const removed = removeBlock(gx, gy);
|
||||
if (removed) {
|
||||
state.inv[removed.t] = (state.inv[removed.t] || 0) + 1;
|
||||
|
||||
// Тратим прочность кирки (если есть в инвентаре)
|
||||
const pickTypes = ['iron_pickaxe', 'stone_pickaxe', 'wood_pickaxe'];
|
||||
for (const pt of pickTypes) {
|
||||
if (state.inv[pt] > 0) {
|
||||
const broke = useTool(pt);
|
||||
if (broke) playSound('cloth1'); // звук поломки
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Отправляем изменение блока на сервер
|
||||
sendBlockChange(gx, gy, removed.t, 'remove');
|
||||
|
||||
// Звуки при добыче блоков
|
||||
if (removed.t === 'glass') playSound('glass1');
|
||||
else if (removed.t === 'sand') playSound('sand1');
|
||||
else if (removed.t === 'snow') playSound('snow1');
|
||||
else if (removed.t === 'stone' || removed.t.endsWith('_ore')) playSound('stone1');
|
||||
else if (removed.t === 'wood') playSound('wood1');
|
||||
else playSound('cloth1');
|
||||
|
||||
rebuildHotbar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode() === 'build') {
|
||||
if (state.inv[state.selected] <= 0) return;
|
||||
if (!BLOCKS[state.selected]) return;
|
||||
if (b) return; // занято
|
||||
|
||||
// Проверяем, ставим ли лодку
|
||||
if (state.selected === 'boat') {
|
||||
// Лодку можно ставить только на воду
|
||||
const waterBelow = getBlock(gx, gy + 1);
|
||||
if (!waterBelow || waterBelow.t !== 'water') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаём лодку
|
||||
state.boat.x = gx * state.TILE;
|
||||
state.boat.y = gy * state.TILE;
|
||||
state.boat.vx = 0;
|
||||
state.boat.vy = 0;
|
||||
state.boat.active = true;
|
||||
state.boat.inWater = true;
|
||||
|
||||
// Сажаем игрока в лодку
|
||||
state.player.inBoat = true;
|
||||
state.player.x = state.boat.x;
|
||||
state.player.y = state.boat.y;
|
||||
state.player.vx = 0;
|
||||
state.player.vy = 0;
|
||||
|
||||
playSound('splash');
|
||||
state.inv[state.selected]--;
|
||||
rebuildHotbar();
|
||||
return;
|
||||
}
|
||||
|
||||
// запрет ставить в игрока
|
||||
const TILE = state.TILE;
|
||||
const bx = gx * TILE, by = gy * TILE;
|
||||
const overlap = !(bx >= state.player.x + state.player.w || bx + TILE <= state.player.x || by >= state.player.y + state.player.h || by + TILE <= state.player.y);
|
||||
if (overlap) return;
|
||||
|
||||
setBlock(gx, gy, state.selected, true); // true = блок установлен игроком
|
||||
state.inv[state.selected]--;
|
||||
|
||||
// Отправляем изменение блока на сервер
|
||||
sendBlockChange(gx, gy, state.selected, 'set');
|
||||
|
||||
// Звук при строительстве
|
||||
if (state.selected === 'stone' || state.selected === 'brick') playSound('stone_build');
|
||||
else if (state.selected === 'wood' || state.selected === 'planks') playSound('wood_build');
|
||||
else if (state.selected === 'glass') playSound('glass1');
|
||||
else if (state.selected === 'sand') playSound('sand1');
|
||||
else if (state.selected === 'snow') playSound('snow1');
|
||||
else if (state.selected === 'dirt' || state.selected === 'grass') playSound('cloth1');
|
||||
|
||||
rebuildHotbar();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
// ==================== ENTRY POINT ====================
|
||||
import { state } from './core/state.js';
|
||||
import { initSocket } from './multiplayer/socket.js';
|
||||
import { sendBlockChange, sendPlayerPosition } from './multiplayer/socket-helpers.js';
|
||||
import { loadSound, playSound } from './audio/sound-engine.js';
|
||||
import { BLOCKS } from './data/blocks.js';
|
||||
import { ITEMS } from './data/items.js';
|
||||
import { TOOLS } from './data/tools.js';
|
||||
import { SMELTING_RECIPES } from './data/recipes.js';
|
||||
import { initTextures } from './render/textures.js';
|
||||
import { getBlock, setBlock, removeBlock } from './world/world-storage.js';
|
||||
import { genColumn, surfaceGyAt, ensureGenAroundCamera, regenerateVisibleChunks } from './world/generation.js';
|
||||
import { initDB, loadGame, applySave, saveGame } from './game/save.js';
|
||||
import { loop } from './game/loop.js';
|
||||
import { initChat } from './ui/chat.js';
|
||||
import { initFurnace } from './ui/furnace.js';
|
||||
import { initMinimap } from './ui/minimap.js';
|
||||
import { initSaveControls, updateSaveButtonVisibility } from './ui/save-controls.js';
|
||||
import { initRespawn } from './ui/respawn.js';
|
||||
import { initShare } from './ui/share.js';
|
||||
import { initControls } from './input/controls.js';
|
||||
import { initMouseHandlers } from './input/mouse-handler.js';
|
||||
import { initModes } from './game/modes.js';
|
||||
import { initVoice } from './multiplayer/voice-chat.js';
|
||||
import { resolveY, resolveX } from './physics/collision.js';
|
||||
import { calculateDamage } from './entities/player.js';
|
||||
import { updateWaterFlag } from './physics/water-detect.js';
|
||||
import { updateWaterPhysics } from './physics/water.js';
|
||||
import { explodeAt, activateTNT } from './world/tnt.js';
|
||||
import { useTool } from './data/tools.js';
|
||||
import { rebuildHotbar } from './ui/hotbar.js';
|
||||
|
||||
// ==================== 1) CONFIG — parse URL/getName ====================
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
state.SERVER_URL = urlParams.get('server') || 'https://apigrech.mkn8n.ru';
|
||||
state.TELEGRAM_BOT_USERNAME = 'Grechkacraft_bot';
|
||||
state.TELEGRAM_APP_SHORT_NAME = 'minegrechka';
|
||||
|
||||
// Защита от mixed content
|
||||
if (location.protocol === 'https:' && state.SERVER_URL.startsWith('http://')) {
|
||||
console.warn('⚠️ Mixed content warning: page is HTTPS but server URL is HTTP');
|
||||
alert('⚠️ Предупреждение: страница загружена по HTTPS, но сервер использует HTTP. Это может вызвать проблемы.');
|
||||
}
|
||||
|
||||
// Player name
|
||||
state.playerName = localStorage.getItem('minegrechka_playerName') || null;
|
||||
if (!state.playerName) {
|
||||
state.playerName = prompt('Введите ваше имя для игры:') || 'Игрок';
|
||||
localStorage.setItem('minegrechka_playerName', state.playerName);
|
||||
console.log('Player name set:', state.playerName);
|
||||
}
|
||||
|
||||
// World ID from URL or generate new
|
||||
console.log('Current URL:', window.location.href);
|
||||
const worldParam = urlParams.get('world');
|
||||
console.log('world param:', worldParam);
|
||||
|
||||
state.worldId = (worldParam && worldParam.trim() !== '') ? worldParam : null;
|
||||
console.log('worldId after params:', state.worldId, 'type:', typeof state.worldId);
|
||||
|
||||
if (!state.worldId) {
|
||||
state.worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Generated worldId:', state.worldId);
|
||||
|
||||
try {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('world', state.worldId);
|
||||
const newUrlString = newUrl.toString();
|
||||
console.log('New URL to set:', newUrlString);
|
||||
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrlString);
|
||||
console.log('URL after replaceState:', window.location.href);
|
||||
} else {
|
||||
console.error('History API not supported!');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error updating URL:', e);
|
||||
}
|
||||
|
||||
console.log('Generated new worldId for browser:', state.worldId);
|
||||
}
|
||||
|
||||
console.log('Final worldId:', state.worldId, 'Player name:', state.playerName);
|
||||
|
||||
// ==================== 2) CANVAS — get elements, resize ====================
|
||||
state.gameEl = document.getElementById('game');
|
||||
state.canvas = document.getElementById('c');
|
||||
state.ctx = state.canvas.getContext('2d');
|
||||
|
||||
// offscreen light map
|
||||
state.lightC = document.createElement('canvas');
|
||||
state.lightCtx = state.lightC.getContext('2d');
|
||||
|
||||
state.dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
|
||||
function resize() {
|
||||
state.W = state.gameEl.clientWidth;
|
||||
state.H = state.gameEl.clientHeight;
|
||||
state.canvas.width = state.W * state.dpr;
|
||||
state.canvas.height = state.H * state.dpr;
|
||||
state.lightC.width = state.W * state.dpr;
|
||||
state.lightC.height = state.H * state.dpr;
|
||||
state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
// ==================== 3) STATE — init player, inv, etc ====================
|
||||
state.otherPlayers = new Map();
|
||||
state.serverMobs = new Map();
|
||||
state.grid = new Map();
|
||||
state.blocks = [];
|
||||
state.generated = new Set();
|
||||
state.serverOverrides = new Map();
|
||||
state.activeFurnaces = new Map();
|
||||
state.activeTNT = new Set();
|
||||
state.toolDurability = new Map();
|
||||
state.worldSeed = Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Clouds
|
||||
state.clouds = Array.from({ length: 10 }, () => ({
|
||||
x: Math.random() * 2000,
|
||||
y: -200 - Math.random() * 260,
|
||||
w: 80 + Math.random() * 120,
|
||||
s: 12 + Math.random() * 20
|
||||
}));
|
||||
|
||||
// Weather
|
||||
state.weatherTimer = 0;
|
||||
state.weatherChangeInterval = 60 + Math.random() * 120;
|
||||
|
||||
// Player spawn
|
||||
state.player.x = 6 * state.TILE;
|
||||
state.player.y = 0;
|
||||
state.spawnPoint.x = 6 * state.TILE;
|
||||
state.spawnPoint.y = 0;
|
||||
|
||||
// Hero image
|
||||
state.heroImg = new Image();
|
||||
state.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
|
||||
|
||||
// UI elements
|
||||
state.hpEl = document.getElementById('hp');
|
||||
state.foodEl = document.getElementById('food');
|
||||
state.sxEl = document.getElementById('sx');
|
||||
state.syEl = document.getElementById('sy');
|
||||
state.todEl = document.getElementById('tod');
|
||||
state.worldIdEl = document.getElementById('worldId');
|
||||
state.playerCountEl = document.getElementById('playerCount');
|
||||
state.deathEl = document.getElementById('death');
|
||||
|
||||
// ==================== 4) TEXTURES — initTextures() ====================
|
||||
initTextures();
|
||||
|
||||
// ==================== 5) AUDIO — loadSound for each ====================
|
||||
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
|
||||
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
|
||||
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
|
||||
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
|
||||
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
|
||||
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
|
||||
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
|
||||
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
|
||||
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
|
||||
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
|
||||
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
|
||||
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
|
||||
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
|
||||
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
|
||||
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
|
||||
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
|
||||
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
|
||||
|
||||
// ==================== 6) CONTROLS — initControls ====================
|
||||
initControls();
|
||||
|
||||
// ==================== 7) MOUSE — initMouseHandlers ====================
|
||||
initMouseHandlers();
|
||||
|
||||
// ==================== 8) UI MODULES — init all UI ====================
|
||||
initChat();
|
||||
initFurnace();
|
||||
initMinimap();
|
||||
initSaveControls();
|
||||
initRespawn();
|
||||
initShare();
|
||||
initModes();
|
||||
|
||||
// ==================== 9) SOCKET — initSocket ====================
|
||||
initSocket();
|
||||
|
||||
// ==================== 10) VOICE — initVoice ====================
|
||||
initVoice();
|
||||
|
||||
// ==================== 11) SAVE — loadGame, applySave ====================
|
||||
rebuildHotbar();
|
||||
|
||||
initDB().then(async () => {
|
||||
const loadedSave = await loadGame();
|
||||
if (loadedSave) {
|
||||
await applySave(loadedSave);
|
||||
console.log('Загружено сохранение, HP:', state.player.hp);
|
||||
|
||||
if (state.player.hp <= 0) {
|
||||
console.log('WARNING: HP <= 0 после загрузки, возрождаемся');
|
||||
state.player.hp = 100;
|
||||
state.player.hunger = 100;
|
||||
state.player.o2 = 100;
|
||||
state.player.x = state.spawnPoint.x;
|
||||
state.player.y = state.spawnPoint.y;
|
||||
state.player.vx = state.player.vy = 0;
|
||||
state.player.invuln = 0;
|
||||
state.player.fallStartY = state.player.y;
|
||||
}
|
||||
} else {
|
||||
console.log('Сохранение не найдено, начинаем новую игру');
|
||||
|
||||
state.player.hp = 100;
|
||||
state.player.hunger = 100;
|
||||
state.player.o2 = 100;
|
||||
state.player.vx = state.player.vy = 0;
|
||||
state.player.invuln = 0;
|
||||
|
||||
// старт — на поверхности
|
||||
const startGX = 6;
|
||||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||||
const surfaceY = surfaceGyAt(startGX);
|
||||
let safeGY = surfaceY - 1;
|
||||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||||
if (aboveBlock && aboveBlock.t === 'water') {
|
||||
for (let gy = state.SEA_GY - 1; gy >= 0; gy--) {
|
||||
const b = getBlock(startGX, gy);
|
||||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||||
safeGY = gy - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.player.y = safeGY * state.TILE;
|
||||
state.player.x = startGX * state.TILE;
|
||||
state.player.fallStartY = state.player.y;
|
||||
|
||||
state.spawnPoint.x = state.player.x;
|
||||
state.spawnPoint.y = state.player.y;
|
||||
|
||||
console.log('Новая игра: startGX=', startGX, 'surfaceY=', surfaceY, 'player.y=', state.player.y);
|
||||
|
||||
// Генерируем карту вокруг стартовой позиции
|
||||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||||
genColumn(gx);
|
||||
}
|
||||
}
|
||||
|
||||
// Автосейв при скрытии страницы
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
saveGame();
|
||||
}
|
||||
});
|
||||
|
||||
// Автосейв перед закрытием страницы
|
||||
window.addEventListener('beforeunload', () => {
|
||||
saveGame();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Ошибка инициализации:', err);
|
||||
const startGX = 6;
|
||||
genColumn(startGX);
|
||||
state.player.y = (surfaceGyAt(startGX) - 1) * state.TILE;
|
||||
state.player.fallStartY = state.player.y;
|
||||
|
||||
for (let gx = startGX - 50; gx <= startGX + 50; gx++) {
|
||||
genColumn(gx);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 13) LOOP — startLoop ====================
|
||||
requestAnimationFrame(loop);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Socket helpers: цвет, отправка позиции/блоков
|
||||
import { state } from '../core/state.js';
|
||||
import { SERVER_URL } from '../config.js';
|
||||
|
||||
// Генерация случайного цвета для игрока на основе socket_id
|
||||
export function getRandomPlayerColor(socketId) {
|
||||
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < socketId.length; i++) {
|
||||
hash = ((hash << 5) - hash) + socketId.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
// Throttle для отправки позиции (10-20 раз в секунду)
|
||||
let lastMoveSendTime = 0;
|
||||
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
|
||||
let lastSentX = 0, lastSentY = 0;
|
||||
|
||||
// Отправка позиции игрока (с throttle)
|
||||
export function sendPlayerPosition() {
|
||||
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
|
||||
|
||||
const now = performance.now() / 1000;
|
||||
if (now - lastMoveSendTime < MOVE_SEND_INTERVAL) return;
|
||||
|
||||
// Отправляем только если позиция изменилась
|
||||
const dx = Math.abs(state.player.x - lastSentX);
|
||||
const dy = Math.abs(state.player.y - lastSentY);
|
||||
if (dx < 1 && dy < 1) return;
|
||||
|
||||
lastMoveSendTime = now;
|
||||
lastSentX = state.player.x;
|
||||
lastSentY = state.player.y;
|
||||
|
||||
state.socket.emit('player_move', { x: state.player.x, y: state.player.y, player_name: state.playerName });
|
||||
}
|
||||
|
||||
// Отправка изменения блока
|
||||
export function sendBlockChange(gx, gy, t, op) {
|
||||
if (!state.isMultiplayer || !state.socket || !state.socket.connected) return;
|
||||
|
||||
state.socket.emit('block_change', { gx, gy, t, op });
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
// Socket.IO клиент — инициализация и все обработчики
|
||||
import { state } from '../core/state.js';
|
||||
import { SERVER_URL, worldId, playerName } from '../config.js';
|
||||
import { TILE, SEA_GY } from '../core/constants.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { k, setBlock, removeBlock, grid, blocks } from '../world/world-storage.js';
|
||||
import { genColumn, surfaceGyAt } from '../world/generation.js';
|
||||
import { addChatMessage } from '../ui/chat.js';
|
||||
import { calculateDamage } from '../entities/player.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { rebuildHotbar } from '../ui/hotbar.js';
|
||||
import { explodeAt } from '../world/tnt.js';
|
||||
import { updateSaveButtonVisibility } from '../ui/save-controls.js';
|
||||
import { getRandomPlayerColor, sendPlayerPosition, sendBlockChange } from './socket-helpers.js';
|
||||
|
||||
let socket = null;
|
||||
|
||||
export { socket };
|
||||
|
||||
export function initSocket() {
|
||||
try {
|
||||
socket = io(SERVER_URL, {
|
||||
path: '/socket.io/',
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log(`Connected to server: ${SERVER_URL}, socket.id: ${socket.id}, worldId: ${worldId}`);
|
||||
state.mySocketId = socket.id;
|
||||
state.isMultiplayer = true;
|
||||
|
||||
// Присоединяемся к миру
|
||||
socket.emit('join_world', { world_id: worldId, player_name: playerName });
|
||||
|
||||
// Показываем в UI
|
||||
state.worldIdEl.textContent = worldId;
|
||||
state.multiplayerStatus.style.display = 'block';
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error);
|
||||
state.isMultiplayer = false;
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
state.isMultiplayer = false;
|
||||
state.otherPlayers.clear();
|
||||
state.multiplayerStatus.style.display = 'none';
|
||||
});
|
||||
|
||||
// Обработка world_state
|
||||
socket.on('world_state', (data) => {
|
||||
console.log('Received world_state:', data);
|
||||
|
||||
// Устанавливаем seed и перегенерируем мир если он изменился
|
||||
if (data.seed !== undefined && data.seed !== state.worldSeed) {
|
||||
const oldSeed = state.worldSeed;
|
||||
state.worldSeed = data.seed;
|
||||
console.log('World seed changed from', oldSeed, 'to', state.worldSeed);
|
||||
|
||||
// Очищаем и перегенерируем мир с новым seed
|
||||
state.generated.clear();
|
||||
grid.clear();
|
||||
blocks.length = 0;
|
||||
state.placedBlocks = [];
|
||||
state.removedBlocks = [];
|
||||
console.log('World regenerated with new seed:', state.worldSeed);
|
||||
}
|
||||
|
||||
// Применяем блоки — сохраняем в state.serverOverrides для применения после genColumn
|
||||
if (data.blocks && Array.isArray(data.blocks)) {
|
||||
for (const block of data.blocks) {
|
||||
const key = k(block.gx, block.gy);
|
||||
state.serverOverrides.set(key, { op: block.op, t: block.t });
|
||||
// Также пробуем применить сразу (если колонна уже сгенерирована)
|
||||
if (block.op === 'set') {
|
||||
setBlock(block.gx, block.gy, block.t, false);
|
||||
} else if (block.op === 'remove') {
|
||||
removeBlock(block.gx, block.gy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем время
|
||||
if (data.time !== undefined) {
|
||||
state.worldTime = data.time;
|
||||
state.isNightTime = state.worldTime > 0.5;
|
||||
}
|
||||
|
||||
// Всегда считаем spawnPoint на клиенте по той же формуле что и surfaceGyAt
|
||||
// Это гарантирует совпадение с terrain generation
|
||||
{
|
||||
const startGX = 6;
|
||||
// Генерируем колонну и соседние для безопасного спавна
|
||||
for (let dx = -3; dx <= 3; dx++) genColumn(startGX + dx);
|
||||
const surfaceY = surfaceGyAt(startGX);
|
||||
// Ищем ближайшую небудущую позицию сверху вниз от поверхности
|
||||
let safeGY = surfaceY - 1;
|
||||
// Проверяем что над поверхностью воздух (не в воде)
|
||||
const aboveBlock = getBlock(startGX, surfaceY - 1);
|
||||
if (aboveBlock && aboveBlock.t === 'water') {
|
||||
// Если в воде — ищем поверхность выше уровня моря
|
||||
for (let gy = SEA_GY - 1; gy >= 0; gy--) {
|
||||
const b = getBlock(startGX, gy);
|
||||
if (!b || b.dead || b.t === 'air' || b.t === 'water') continue;
|
||||
safeGY = gy - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.spawnPoint.x = startGX * TILE;
|
||||
state.spawnPoint.y = safeGY * TILE;
|
||||
console.log('Client-side spawn point:', state.spawnPoint, 'surfaceY:', surfaceY, 'safeGY:', safeGY);
|
||||
}
|
||||
|
||||
// Устанавливаем игрока в точку спавна
|
||||
state.player.x = state.spawnPoint.x;
|
||||
state.player.y = state.spawnPoint.y;
|
||||
state.player.vx = 0;
|
||||
state.player.vy = 0;
|
||||
state.player.fallStartY = state.player.y;
|
||||
console.log('Player moved to spawn point:', state.player.x, state.player.y);
|
||||
|
||||
// Устанавливаем HP на 100% при каждом подключении к миру
|
||||
state.player.hp = 100;
|
||||
state.player.hunger = 100;
|
||||
state.player.o2 = 100;
|
||||
state.player.invuln = 0;
|
||||
console.log('[MULTIPLAYER CONNECT] Player HP set to 100% on connect');
|
||||
|
||||
// Обновляем список игроков
|
||||
if (data.players && Array.isArray(data.players)) {
|
||||
state.otherPlayers.clear();
|
||||
for (const p of data.players) {
|
||||
if (p.socket_id !== state.mySocketId) {
|
||||
state.otherPlayers.set(p.socket_id, {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
color: getRandomPlayerColor(p.socket_id),
|
||||
name: p.player_name || 'Игрок'
|
||||
});
|
||||
}
|
||||
}
|
||||
// Обновляем счётчик игроков
|
||||
state.playerCountEl.textContent = data.players.length;
|
||||
}
|
||||
// Server mobs
|
||||
if (data.mobs && Array.isArray(data.mobs)) {
|
||||
state.serverMobs.clear();
|
||||
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 };
|
||||
state.serverMobs.set(m.id, sm);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Игрок присоединился
|
||||
socket.on('player_joined', (data) => {
|
||||
console.log('Player joined:', data.socket_id);
|
||||
if (data.socket_id !== state.mySocketId) {
|
||||
// Генерируем безопасную позицию для нового игрока
|
||||
const spawnGX = 6;
|
||||
genColumn(spawnGX);
|
||||
const surfaceY = surfaceGyAt(spawnGX);
|
||||
const safeSpawnX = spawnGX * TILE;
|
||||
const safeSpawnY = (surfaceY - 1) * TILE;
|
||||
|
||||
state.otherPlayers.set(data.socket_id, {
|
||||
x: safeSpawnX,
|
||||
y: safeSpawnY,
|
||||
color: getRandomPlayerColor(data.socket_id),
|
||||
name: data.player_name || 'Игрок'
|
||||
});
|
||||
addChatMessage('Система', `Игрок присоединился`);
|
||||
// Обновляем видимость кнопки сохранения
|
||||
updateSaveButtonVisibility();
|
||||
}
|
||||
});
|
||||
|
||||
// Игрок переместился
|
||||
socket.on('player_moved', (data) => {
|
||||
if (data.socket_id !== state.mySocketId && state.otherPlayers.has(data.socket_id)) {
|
||||
const p = state.otherPlayers.get(data.socket_id);
|
||||
p.x = data.x;
|
||||
p.y = data.y;
|
||||
// Обновляем имя, если оно пришло
|
||||
if (data.player_name) {
|
||||
p.name = data.player_name;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Игрок покинул
|
||||
socket.on('player_left', (data) => {
|
||||
console.log('Player left:', data.socket_id);
|
||||
state.otherPlayers.delete(data.socket_id);
|
||||
addChatMessage('Система', `Игрок покинул игру`);
|
||||
// Обновляем видимость кнопки сохранения
|
||||
updateSaveButtonVisibility();
|
||||
});
|
||||
|
||||
// === MOB SYNC (multiplayer) ===
|
||||
|
||||
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 };
|
||||
state.serverMobs.set(data.id, sm);
|
||||
});
|
||||
|
||||
socket.on('mob_positions', (arr) => {
|
||||
for (const u of arr) {
|
||||
const sm = state.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; }
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('mob_despawned', (data) => { state.serverMobs.delete(data.id); });
|
||||
|
||||
socket.on('mob_died', (data) => {
|
||||
const sm = state.serverMobs.get(data.id);
|
||||
if (sm && data.killer === state.mySocketId) {
|
||||
// Give loot to the killer
|
||||
if (sm.kind === 'chicken') playSound('hurt_chicken');
|
||||
state.inv.meat += (sm.kind === 'chicken' ? 1 : 2);
|
||||
if (sm.kind === 'skeleton') {
|
||||
state.inv.arrow += 2 + Math.floor(Math.random() * 3);
|
||||
if (Math.random() < 0.15) state.inv.bow = (state.inv.bow || 0) + 1;
|
||||
}
|
||||
rebuildHotbar();
|
||||
}
|
||||
state.serverMobs.delete(data.id);
|
||||
});
|
||||
|
||||
socket.on('mob_hurt_ack', (data) => {
|
||||
const sm = state.serverMobs.get(data.id);
|
||||
if (sm) sm.hp = data.hp;
|
||||
});
|
||||
|
||||
socket.on('mob_explode', (data) => {
|
||||
explodeAt(data.gx, data.gy);
|
||||
state.serverMobs.delete(data.id);
|
||||
});
|
||||
|
||||
socket.on('mob_shoot', (data) => {
|
||||
state.projectiles.push({
|
||||
x: data.x, y: data.y, vx: data.vx, vy: data.vy,
|
||||
dmg: data.dmg, owner: 'mob', life: data.life
|
||||
});
|
||||
});
|
||||
|
||||
// Блок изменён
|
||||
socket.on('block_changed', (data) => {
|
||||
const key = k(data.gx, data.gy);
|
||||
state.serverOverrides.set(key, { op: data.op, t: data.t });
|
||||
if (data.op === 'set') {
|
||||
setBlock(data.gx, data.gy, data.t, false);
|
||||
} else if (data.op === 'remove') {
|
||||
removeBlock(data.gx, data.gy);
|
||||
}
|
||||
});
|
||||
|
||||
// Сообщение в чат
|
||||
socket.on('chat_message', (data) => {
|
||||
const senderName = data.socket_id === state.mySocketId ? 'Вы' : `Игрок ${data.socket_id.substring(0, 6)}`;
|
||||
addChatMessage(senderName, data.message);
|
||||
});
|
||||
|
||||
// Обновление времени
|
||||
socket.on('time_update', (data) => {
|
||||
if (data.time !== undefined) {
|
||||
state.worldTime = data.time;
|
||||
state.isNightTime = state.worldTime > 0.5;
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error initializing socket:', e);
|
||||
state.isMultiplayer = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
// Голосовой чат
|
||||
import { state } from '../core/state.js';
|
||||
import { worldId } from '../config.js';
|
||||
|
||||
let voiceSocket = null;
|
||||
let voiceStream = null;
|
||||
let audioCtx = null;
|
||||
let voiceProcessor = null;
|
||||
let voiceActive = false;
|
||||
const VOICE_SERVER = 'https://voicegrech.mkn8n.ru';
|
||||
|
||||
// Кнопка микрофона
|
||||
const voiceBtn = document.createElement('div');
|
||||
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.title = 'Голосовой чат (выкл)';
|
||||
voiceBtn.style.cssText = 'position:absolute;top:74px;right:130px;width:52px;height:52px;border-radius:12px;background:#555;z-index:200;display:flex;align-items:center;justify-content:center;font-size:24px;cursor:pointer;border:2px solid rgba(255,255,255,0.9);box-shadow:0 4px 0 rgba(0,0,0,0.5);pointer-events:auto;';
|
||||
document.querySelector('.ui').appendChild(voiceBtn);
|
||||
|
||||
// Индикатор говорящего
|
||||
const 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.textContent = '🔊';
|
||||
document.querySelector('.ui').appendChild(speakingIndicator);
|
||||
let speakingTimeout = null;
|
||||
|
||||
voiceBtn.onclick = async () => {
|
||||
if (voiceActive) {
|
||||
// Выключить
|
||||
voiceActive = 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) {
|
||||
voiceStream.getTracks().forEach(t => t.stop());
|
||||
voiceStream = null;
|
||||
}
|
||||
if (voiceProcessor) { voiceProcessor.disconnect(); voiceProcessor = null; }
|
||||
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
||||
if (voiceSocket) { voiceSocket.disconnect(); voiceSocket = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Включить
|
||||
try {
|
||||
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
|
||||
audioCtx = new AudioContext({ sampleRate: 24000 });
|
||||
const source = audioCtx.createMediaStreamSource(voiceStream);
|
||||
voiceProcessor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
voiceProcessor.onaudioprocess = (e) => {
|
||||
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]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
voiceSocket.emit('voice_data', int16.buffer);
|
||||
};
|
||||
|
||||
source.connect(voiceProcessor);
|
||||
voiceProcessor.connect(audioCtx.createGain()); // не в destination — чтобы не слышать себя
|
||||
|
||||
// Подключаемся к голосовому серверу
|
||||
voiceSocket = io(VOICE_SERVER, { transports: ['websocket'] });
|
||||
voiceSocket.on('connect', () => {
|
||||
voiceSocket.emit('voice_join', { world_id: worldId, x: state.player.x, y: state.player.y, name: state.playerName || 'Игрок' });
|
||||
});
|
||||
|
||||
voiceSocket.on('voice_in', (payload) => {
|
||||
// Воспроизводим входящий голос
|
||||
const { data, meta, volume } = payload;
|
||||
if (!audioCtx || audioCtx.state === 'closed') return;
|
||||
|
||||
// Int16 → Float32
|
||||
const int16 = new Int16Array(data);
|
||||
const float32 = new Float32Array(int16.length);
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF) * volume;
|
||||
}
|
||||
|
||||
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 = `🔊 ${meta.name}`;
|
||||
clearTimeout(speakingTimeout);
|
||||
speakingTimeout = setTimeout(() => { speakingIndicator.style.display = 'none'; }, 500);
|
||||
});
|
||||
|
||||
voiceActive = true;
|
||||
voiceBtn.textContent = '🎤';
|
||||
voiceBtn.style.background = '#2ecc71';
|
||||
} catch (e) {
|
||||
console.error('Voice error:', e);
|
||||
voiceBtn.style.background = '#e74c3c';
|
||||
}
|
||||
};
|
||||
|
||||
export function initVoice() {
|
||||
// Voice position update hook — should be called from main loop
|
||||
let voicePosT = 0;
|
||||
return {
|
||||
update(dt) {
|
||||
voicePosT += dt;
|
||||
if (voicePosT > 0.5 && voiceSocket && voiceSocket.connected) {
|
||||
voicePosT = 0;
|
||||
voiceSocket.emit('voice_pos', { x: state.player.x, y: state.player.y });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { TILE } from '../core/constants.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { state } from '../core/state.js';
|
||||
import { getBlock, isSolid } from '../world/world-storage.js';
|
||||
import { calculateDamage } from '../entities/player.js';
|
||||
|
||||
function resolveY(e) {
|
||||
// Всегда пересчитываем grounded (не держим "липким")
|
||||
e.grounded = false;
|
||||
|
||||
const x1 = e.x + 2;
|
||||
const x2 = e.x + e.w - 2;
|
||||
|
||||
// Проверяем, находится ли игрок на лестнице (по центру)
|
||||
const cx = e.x + e.w / 2;
|
||||
const cy = e.y + e.h / 2;
|
||||
const gx = Math.floor(cx / TILE);
|
||||
const gy = Math.floor(cy / TILE);
|
||||
const b = getBlock(gx, gy);
|
||||
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
|
||||
|
||||
// Если на лестнице - можно двигаться вверх/вниз
|
||||
if (onLadder) {
|
||||
e.grounded = true;
|
||||
|
||||
// Если нажимаем прыжок на лестнице - поднимаемся
|
||||
if (state.inp.jump) {
|
||||
e.vy = -200;
|
||||
}
|
||||
// Если нажимаем вниз - спускаемся
|
||||
else if (state.inp.down) {
|
||||
e.vy = 100;
|
||||
}
|
||||
// Иначе - остаёмся на месте (нет гравитации)
|
||||
else {
|
||||
e.vy = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, можно ли запрыгнуть на лестницу рядом (слева или справа)
|
||||
const leftGX = Math.floor((e.x - 4) / TILE);
|
||||
const rightGX = Math.floor((e.x + e.w + 4) / TILE);
|
||||
const playerGY = Math.floor((e.y + e.h / 2) / TILE);
|
||||
|
||||
const leftBlock = getBlock(leftGX, playerGY);
|
||||
const rightBlock = getBlock(rightGX, playerGY);
|
||||
const leftLadder = leftBlock && BLOCKS[leftBlock.t] && BLOCKS[leftBlock.t].climbable;
|
||||
const rightLadder = rightBlock && BLOCKS[rightBlock.t] && BLOCKS[rightBlock.t].climbable;
|
||||
|
||||
// Если рядом есть лестница и игрок прыгает - притягиваем к ней
|
||||
if ((leftLadder || rightLadder) && state.inp.jump && e.vy < 0) {
|
||||
// Перемещаем игрока к лестнице
|
||||
if (leftLadder && e.x > leftGX * TILE + TILE / 2) {
|
||||
e.x = leftGX * TILE + TILE / 2 - e.w / 2;
|
||||
} else if (rightLadder && e.x < rightGX * TILE + TILE / 2) {
|
||||
e.x = rightGX * TILE + TILE / 2 - e.w / 2;
|
||||
}
|
||||
e.grounded = true;
|
||||
e.vy = -150; // меньший прыжок при запрыгивании на лестницу
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Если движемся вниз ИЛИ стоим (vy >= 0) — проверяем опору под ногами
|
||||
// Берём точку на 1px ниже стопы, чтобы не зависеть от ровного попадания в границу тайла.
|
||||
if (e.vy >= 0) {
|
||||
const probeY = e.y + e.h + 1;
|
||||
const gy = Math.floor(probeY / TILE);
|
||||
const gxA = Math.floor(x1 / TILE);
|
||||
const gxB = Math.floor(x2 / TILE);
|
||||
|
||||
if (isSolid(gxA, gy) || isSolid(gxB, gy)) {
|
||||
e.y = gy * TILE - e.h; // прижимаем к полу
|
||||
e.vy = 0;
|
||||
e.grounded = true;
|
||||
|
||||
// урон от падения — только игроку и только не в воде
|
||||
if (e === state.player && !state.player.inWater) {
|
||||
const fallTiles = (e.y - e.fallStartY) / TILE;
|
||||
if (fallTiles > 6) {
|
||||
const damage = calculateDamage((fallTiles - 6) * 10);
|
||||
state.player.hp -= damage;
|
||||
}
|
||||
}
|
||||
if (e === state.player) e.fallStartY = e.y;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Проверка на возможность запрыгнуть на блок, если игрок рядом с ним
|
||||
if (e.vy < 0 && e === state.player) {
|
||||
const gy = Math.floor(e.y / TILE);
|
||||
const gxA = Math.floor(x1 / TILE);
|
||||
const gxB = Math.floor(x2 / TILE);
|
||||
|
||||
// Проверяем, есть ли блок рядом с игроком
|
||||
if ((isSolid(gxA, gy) || isSolid(gxB, gy)) && !isSolid(gxA, gy - 1) && !isSolid(gxB, gy - 1)) {
|
||||
e.y = (gy + 1) * TILE;
|
||||
e.vy = 0;
|
||||
e.grounded = true;
|
||||
if (e === state.player) e.fallStartY = e.y;
|
||||
console.log("Jumped onto block!");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Если движемся вверх — проверяем потолок
|
||||
if (e.vy < 0) {
|
||||
const gy = Math.floor(e.y / TILE);
|
||||
const gxA = Math.floor(x1 / TILE);
|
||||
const gxB = Math.floor(x2 / TILE);
|
||||
if (isSolid(gxA, gy) || isSolid(gxB, gy)) {
|
||||
e.y = (gy + 1) * TILE;
|
||||
e.vy = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveX(e) {
|
||||
const y1 = e.y + 2;
|
||||
const y2 = e.y + e.h - 2;
|
||||
|
||||
// Проверяем, находимся ли мы на лестнице
|
||||
const cx = e.x + e.w / 2;
|
||||
const cy = e.y + e.h / 2;
|
||||
const gx = Math.floor(cx / TILE);
|
||||
const gy = Math.floor(cy / TILE);
|
||||
const b = getBlock(gx, gy);
|
||||
const onLadder = b && BLOCKS[b.t] && BLOCKS[b.t].climbable;
|
||||
|
||||
if (e.vx > 0) {
|
||||
const gx = Math.floor((e.x + e.w) / TILE);
|
||||
const gyA = Math.floor(y1 / TILE);
|
||||
const gyB = Math.floor(y2 / TILE);
|
||||
const solidA = isSolid(gx, gyA);
|
||||
const solidB = isSolid(gx, gyB);
|
||||
|
||||
if (solidA || solidB) {
|
||||
e.x = gx * TILE - e.w;
|
||||
e.vx = 0;
|
||||
}
|
||||
} else if (e.vx < 0) {
|
||||
const gx = Math.floor(e.x / TILE);
|
||||
const gyA = Math.floor(y1 / TILE);
|
||||
const gyB = Math.floor(y2 / TILE);
|
||||
const solidA = isSolid(gx, gyA);
|
||||
const solidB = isSolid(gx, gyB);
|
||||
|
||||
if (solidA || solidB) {
|
||||
e.x = (gx + 1) * TILE;
|
||||
e.vx = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { resolveY, resolveX };
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { TILE } from '../core/constants.js';
|
||||
import { state } from '../core/state.js';
|
||||
import { getBlock } from '../world/world-storage.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
|
||||
function isWaterAt(px, py) {
|
||||
const gx = Math.floor(px / TILE);
|
||||
const gy = Math.floor(py / TILE);
|
||||
const b = getBlock(gx, gy);
|
||||
return !!(b && b.t === 'water');
|
||||
}
|
||||
|
||||
function updateWaterFlag(e) {
|
||||
const cx = e.x + e.w / 2;
|
||||
const wasInWater = e.inWater;
|
||||
|
||||
// В воде, если в воде хотя бы центр/ноги (чтобы корректно работать у поверхности)
|
||||
const mid = isWaterAt(cx, e.y + e.h / 2);
|
||||
const feet = isWaterAt(cx, e.y + e.h - 2);
|
||||
e.inWater = mid || feet;
|
||||
|
||||
// Голова под водой — для кислорода/урона
|
||||
e.headInWater = isWaterAt(cx, e.y + 4);
|
||||
|
||||
// Звук при падении в воду
|
||||
if (e === state.player && !wasInWater && e.inWater && e.vy > 100) {
|
||||
playSound('splash');
|
||||
}
|
||||
}
|
||||
|
||||
export { isWaterAt, updateWaterFlag };
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Рисование костра: огонь поверх текстуры
|
||||
export function drawFire(ctx, wx, wy, now) {
|
||||
const baseX = wx;
|
||||
const baseY = wy;
|
||||
const flick = 6 + (Math.sin(now / 90) + 1) * 4;
|
||||
ctx.fillStyle = 'rgba(255,140,0,0.85)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseX + 10, baseY + 30);
|
||||
ctx.lineTo(baseX + 20, baseY + 30 - flick);
|
||||
ctx.lineTo(baseX + 30, baseY + 30);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = 'rgba(255,230,150,0.75)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseX + 14, baseY + 30);
|
||||
ctx.lineTo(baseX + 20, baseY + 30 - (flick * 0.7));
|
||||
ctx.lineTo(baseX + 26, baseY + 30);
|
||||
ctx.fill();
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// Lighting overlay at night (Minecraft-style: offscreen lightmap, wall occlusion, warm flicker)
|
||||
import { state } from '../core/state.js';
|
||||
import { TILE } from '../core/constants.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { getBlock } from '../world/world-storage.js';
|
||||
|
||||
export function renderLighting(ctx, W, H, camX, camY, lightC, lightCtx, dpr, blocks, minGX, maxGX, minGY, maxGY, now) {
|
||||
// 1) Рисуем тёмный оверлей на offscreen canvas
|
||||
lightC.width = W * dpr;
|
||||
lightC.height = H * dpr;
|
||||
lightCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
lightCtx.fillStyle = 'rgba(0,0,12,0.82)';
|
||||
lightCtx.fillRect(0, 0, W, H);
|
||||
|
||||
// 2) Вырезаем «окна света» через destination-out — свет не проходит сквозь стены
|
||||
lightCtx.globalCompositeOperation = 'destination-out';
|
||||
|
||||
// Функция: рисуем мягкий луч света с затуханием за стенами
|
||||
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 r = radius * flick;
|
||||
// 12 лучей — достаточно для мягкого круга
|
||||
const steps = 12;
|
||||
// Собираем дистанции до стен по лучам
|
||||
const dists = new Float32Array(steps);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const angle = (i / steps) * Math.PI * 2;
|
||||
const dx = Math.cos(angle);
|
||||
const dy = Math.sin(angle);
|
||||
let maxDist = r;
|
||||
// Идём по лучу пока не упрёмся в стену
|
||||
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);
|
||||
if (blk && !blk.dead && BLOCKS[blk.t] && BLOCKS[blk.t].solid && step > TILE * 0.3) {
|
||||
maxDist = step;
|
||||
break;
|
||||
}
|
||||
}
|
||||
dists[i] = maxDist;
|
||||
}
|
||||
// Рисуем сглаженный полигон по dists
|
||||
const cx = sx - camX, cy = sy - camY;
|
||||
// Центр: яркая точка
|
||||
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.5, 'rgba(255,255,255,0.65)');
|
||||
grad.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
lightCtx.fillStyle = grad;
|
||||
// Рисуем shape по dists (звездоподобный полигон)
|
||||
lightCtx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const idx = i % steps;
|
||||
const nextIdx = (i + 1) % steps;
|
||||
const avgD = (dists[idx] + dists[nextIdx]) / 2;
|
||||
const angle = (idx / steps) * Math.PI * 2;
|
||||
const px = cx + Math.cos(angle) * dists[idx];
|
||||
const py = cy + Math.sin(angle) * dists[idx];
|
||||
if (i === 0) lightCtx.moveTo(px, py);
|
||||
else lightCtx.lineTo(px, py);
|
||||
}
|
||||
lightCtx.closePath();
|
||||
lightCtx.fill();
|
||||
}
|
||||
|
||||
// Источники света
|
||||
for (const b of blocks) {
|
||||
if (b.dead) continue;
|
||||
if (b.gx < minGX - 5 || b.gx > maxGX + 5 || b.gy < minGY - 5 || b.gy > maxGY + 5) continue;
|
||||
const def = BLOCKS[b.t];
|
||||
if (def.lightRadius) {
|
||||
castLight(b.gx * TILE + TILE / 2, b.gy * TILE + TILE / 2, def.lightRadius);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Накладываем lightmap на основной canvas
|
||||
lightCtx.globalCompositeOperation = 'source-over';
|
||||
ctx.drawImage(lightC, 0, 0, W, H);
|
||||
|
||||
// 4) Тёплый оверлей от источников света (additive, мягкий)
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
for (const b of blocks) {
|
||||
if (b.dead) continue;
|
||||
if (b.gx < minGX - 3 || b.gx > maxGX + 3 || b.gy < minGY - 3 || b.gy > maxGY + 3) continue;
|
||||
const def = BLOCKS[b.t];
|
||||
if (def.lightRadius) {
|
||||
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.6 * flick;
|
||||
const grad = ctx.createRadialGradient(wx, wy, 0, wx, wy, r);
|
||||
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();
|
||||
ctx.arc(wx, wy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Частицы (взрыв)
|
||||
export const parts = [];
|
||||
|
||||
export function spawnExplosion(x, y, power) {
|
||||
const n = Math.floor(16 + power * 10);
|
||||
for (let i = 0; i < n; i++) {
|
||||
parts.push({
|
||||
x, y,
|
||||
vx: (Math.random() - 0.5) * (300 + power * 200),
|
||||
vy: (Math.random() - 0.5) * (300 + power * 200),
|
||||
t: 0.7, c: '#ffa500'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// Текстуры блоков (простые)
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
|
||||
export const tex = {};
|
||||
|
||||
export function makeTex(type) {
|
||||
const t = BLOCKS[type];
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 32; c.height = 32;
|
||||
const g = c.getContext('2d');
|
||||
|
||||
if (type === 'tnt') {
|
||||
g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
|
||||
g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
|
||||
g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
|
||||
return c;
|
||||
}
|
||||
if (type === 'campfire') {
|
||||
g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
|
||||
g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
|
||||
return c;
|
||||
}
|
||||
if (type === 'torch') {
|
||||
g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
|
||||
g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
|
||||
return c;
|
||||
}
|
||||
if (type === 'glass') {
|
||||
g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
|
||||
g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
|
||||
g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
|
||||
return c;
|
||||
}
|
||||
if (type === 'water') {
|
||||
g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
|
||||
g.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
g.fillRect(0, 6, 32, 2);
|
||||
return c;
|
||||
}
|
||||
if (type === 'bed') {
|
||||
// Основание кровати
|
||||
g.fillStyle = '#e91e63';
|
||||
g.fillRect(0, 0, 32, 32);
|
||||
// Подушка
|
||||
g.fillStyle = '#f8bbd0';
|
||||
g.fillRect(2, 2, 14, 14);
|
||||
// Одеяло
|
||||
g.fillStyle = '#c2185b';
|
||||
g.fillRect(16, 4, 14, 24);
|
||||
// Детали одеяла
|
||||
g.fillStyle = '#e91e63';
|
||||
g.fillRect(18, 6, 10, 20);
|
||||
return c;
|
||||
}
|
||||
if (type === 'flower') {
|
||||
g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
|
||||
g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
|
||||
return c;
|
||||
}
|
||||
if (type === 'boat') {
|
||||
// Корпус лодки
|
||||
g.fillStyle = '#8B4513';
|
||||
g.fillRect(2, 12, 28, 8);
|
||||
// Борта
|
||||
g.fillStyle = '#A0522D';
|
||||
g.fillRect(0, 10, 32, 12);
|
||||
// Внутренность
|
||||
g.fillStyle = '#DEB887';
|
||||
g.fillRect(4, 14, 24, 4);
|
||||
// Дно
|
||||
g.fillStyle = '#654321';
|
||||
g.fillRect(2, 20, 28, 4);
|
||||
return c;
|
||||
}
|
||||
if (type === 'ladder') {
|
||||
// Боковые стойки лестницы
|
||||
g.fillStyle = '#8B4513';
|
||||
g.fillRect(4, 0, 4, 32);
|
||||
g.fillRect(24, 0, 4, 32);
|
||||
// Ступени
|
||||
g.fillStyle = '#A0522D';
|
||||
g.fillRect(4, 4, 24, 3);
|
||||
g.fillRect(4, 12, 24, 3);
|
||||
g.fillRect(4, 20, 24, 3);
|
||||
g.fillRect(4, 28, 24, 3);
|
||||
return c;
|
||||
}
|
||||
|
||||
g.fillStyle = t.c || '#000';
|
||||
g.fillRect(0, 0, 32, 32);
|
||||
|
||||
g.fillStyle = 'rgba(0,0,0,0.10)';
|
||||
for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
|
||||
|
||||
if (type.endsWith('_ore') || type === 'coal') {
|
||||
g.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
export function initTextures() {
|
||||
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// ==================== ЧАТ ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
|
||||
const chatMessages = [];
|
||||
const MAX_CHAT_MESSAGES = 20;
|
||||
|
||||
export function addChatMessage(sender, message) {
|
||||
const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
chatMessages.push({ sender, message, time });
|
||||
if (chatMessages.length > MAX_CHAT_MESSAGES) {
|
||||
chatMessages.shift();
|
||||
}
|
||||
renderChatMessages();
|
||||
}
|
||||
|
||||
export function renderChatMessages() {
|
||||
const chatMessagesEl = document.getElementById('chatMessages');
|
||||
if (!chatMessagesEl) return;
|
||||
|
||||
chatMessagesEl.innerHTML = chatMessages.map(m =>
|
||||
`<div style="margin-bottom:4px;"><span style="color:#aaa;font-size:11px;">${m.time}</span> <strong style="color:${m.sender === 'Система' ? '#f39c12' : '#3498db'};">${m.sender}:</strong> ${m.message}</div>`
|
||||
).join('');
|
||||
|
||||
// Прокручиваем вниз
|
||||
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
export function sendChatMessage(message) {
|
||||
if (!message || message.trim() === '') return;
|
||||
|
||||
if (state.isMultiplayer && state.socket && state.socket.connected) {
|
||||
state.socket.emit('chat_message', { message: message.trim() });
|
||||
} else {
|
||||
addChatMessage('Вы', message.trim());
|
||||
}
|
||||
}
|
||||
|
||||
export function initChat() {
|
||||
document.getElementById('chatToggle').onclick = () => {
|
||||
playSound('click');
|
||||
state.chatOpen = !state.chatOpen;
|
||||
document.getElementById('chatPanel').style.display = state.chatOpen ? 'block' : 'none';
|
||||
if (state.chatOpen) {
|
||||
document.getElementById('chatInput').focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('chatClose').onclick = () => {
|
||||
playSound('click');
|
||||
state.chatOpen = false;
|
||||
document.getElementById('chatPanel').style.display = 'none';
|
||||
};
|
||||
|
||||
document.getElementById('chatSend').onclick = () => {
|
||||
const input = document.getElementById('chatInput');
|
||||
sendChatMessage(input.value);
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
document.getElementById('chatInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
sendChatMessage(e.target.value);
|
||||
e.target.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
// Craft UI
|
||||
import { state } from '../core/state.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { ITEMS } from '../data/items.js';
|
||||
import { TOOLS } from '../data/tools.js';
|
||||
import { RECIPES } from '../data/recipes.js';
|
||||
import { addTool } from '../data/tools.js';
|
||||
import { tex } from '../render/textures.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { rebuildHotbar } from './hotbar.js';
|
||||
import { renderInventory } from './inventory.js';
|
||||
|
||||
export function canCraft(r) {
|
||||
const inv = state.inv;
|
||||
console.log('[CRAFT] Checking recipe for:', r.out, '- cost:', r.cost);
|
||||
for (const res in r.cost) {
|
||||
const have = inv[res] || 0;
|
||||
const need = r.cost[res];
|
||||
console.log('[CRAFT] Resource:', res, '- have:', have, '- need:', need, '- can craft:', have >= need);
|
||||
if (have < need) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function renderCraft() {
|
||||
const inv = state.inv;
|
||||
const recipesEl = state.recipesEl;
|
||||
recipesEl.innerHTML = '';
|
||||
for (const r of RECIPES) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'recipe';
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'ricon';
|
||||
// Иконка — блок, инструмент или предмет
|
||||
if (tex[r.out]) {
|
||||
icon.style.backgroundImage = `url(${tex[r.out].toDataURL()})`;
|
||||
} else if (TOOLS[r.out]) {
|
||||
icon.textContent = TOOLS[r.out].icon;
|
||||
icon.style.fontSize = '24px';
|
||||
icon.style.display = 'flex';
|
||||
icon.style.alignItems = 'center';
|
||||
icon.style.justifyContent = 'center';
|
||||
} else if (ITEMS[r.out]) {
|
||||
icon.textContent = ITEMS[r.out].icon;
|
||||
icon.style.fontSize = '24px';
|
||||
icon.style.display = 'flex';
|
||||
icon.style.alignItems = 'center';
|
||||
icon.style.justifyContent = 'center';
|
||||
}
|
||||
const info = document.createElement('div');
|
||||
info.className = 'rinfo';
|
||||
const nm = document.createElement('div');
|
||||
nm.className = 'rname';
|
||||
const itemName = BLOCKS[r.out]?.n || TOOLS[r.out]?.n || ITEMS[r.out]?.n || r.out;
|
||||
nm.textContent = `${itemName} x${r.qty}`;
|
||||
const cs = document.createElement('div');
|
||||
cs.className = 'rcost';
|
||||
cs.textContent = Object.keys(r.cost).map(x => {
|
||||
const cn = BLOCKS[x]?.n || TOOLS[x]?.n || ITEMS[x]?.n || x;
|
||||
return `${cn}: ${(inv[x] || 0)}/${r.cost[x]}`;
|
||||
}).join(' ');
|
||||
info.appendChild(nm); info.appendChild(cs);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'rcraft';
|
||||
btn.textContent = 'Создать';
|
||||
btn.disabled = !canCraft(r);
|
||||
btn.onclick = () => {
|
||||
if (!canCraft(r)) return;
|
||||
playSound('click');
|
||||
for (const res in r.cost) inv[res] -= r.cost[res];
|
||||
inv[r.out] = (inv[r.out] || 0) + r.qty;
|
||||
if (TOOLS[r.out]) addTool(r.out);
|
||||
rebuildHotbar();
|
||||
renderCraft();
|
||||
};
|
||||
row.appendChild(icon); row.appendChild(info); row.appendChild(btn);
|
||||
recipesEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
let craftOpen = false;
|
||||
let inventoryOpen = false;
|
||||
|
||||
export function toggleCraft() {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
craftOpen = !craftOpen;
|
||||
state.craftPanel.style.display = craftOpen ? 'block' : 'none';
|
||||
if (craftOpen) {
|
||||
renderCraft();
|
||||
// Закрываем инвентарь если открыт крафт
|
||||
inventoryOpen = false;
|
||||
state.inventoryPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function closeCraft() {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
craftOpen = false;
|
||||
state.craftPanel.style.display = 'none';
|
||||
}
|
||||
|
||||
export function toggleInventory() {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
inventoryOpen = true;
|
||||
state.inventoryPanel.style.display = 'block';
|
||||
renderInventory();
|
||||
// Закрываем крафт если открыт инвентарь
|
||||
craftOpen = false;
|
||||
state.craftPanel.style.display = 'none';
|
||||
}
|
||||
|
||||
export function closeInventory() {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
inventoryOpen = false;
|
||||
state.inventoryPanel.style.display = 'none';
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// DOM-ссылки — все document.getElementById
|
||||
export const hpEl = document.getElementById('hp');
|
||||
export const foodEl = document.getElementById('food');
|
||||
export const sxEl = document.getElementById('sx');
|
||||
export const syEl = document.getElementById('sy');
|
||||
export const todEl = document.getElementById('tod');
|
||||
export const worldIdEl = document.getElementById('worldId');
|
||||
export const playerCountEl = document.getElementById('playerCount');
|
||||
export const hotbarEl = document.getElementById('hotbar');
|
||||
export const craftPanel = document.getElementById('craftPanel');
|
||||
export const recipesEl = document.getElementById('recipes');
|
||||
export const deathEl = document.getElementById('death');
|
||||
export const inventoryPanel = document.getElementById('inventoryPanel');
|
||||
export const inventoryGrid = document.getElementById('inventoryGrid');
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
// ==================== ПЕЧЬ (ОБЖИГ) ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { ITEMS } from '../data/items.js';
|
||||
import { SMELTING_RECIPES } from '../data/recipes.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { rebuildHotbar } from '../ui/hotbar.js';
|
||||
import { getBlock } from '../world/world-storage.js';
|
||||
|
||||
const furnacePanel = document.getElementById('furnacePanel');
|
||||
const furnaceContent = document.getElementById('furnaceContent');
|
||||
|
||||
export function initFurnace() {
|
||||
document.getElementById('furnaceClose').onclick = () => {
|
||||
furnacePanel.style.display = 'none';
|
||||
state.currentFurnaceKey = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function openFurnaceUI(gx, gy) {
|
||||
state.currentFurnaceKey = `${gx},${gy}`;
|
||||
furnacePanel.style.display = 'block';
|
||||
renderFurnaceUI();
|
||||
}
|
||||
|
||||
export function renderFurnaceUI() {
|
||||
if (!state.currentFurnaceKey) return;
|
||||
|
||||
// Проверяем что печь всё ещё существует
|
||||
const [fgx, fgy] = state.currentFurnaceKey.split(',').map(Number);
|
||||
const fb = getBlock(fgx, fgy);
|
||||
if (!fb || fb.t !== 'furnace') {
|
||||
furnacePanel.style.display = 'none';
|
||||
state.currentFurnaceKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Текущий процесс обжига
|
||||
const active = state.activeFurnaces.get(state.currentFurnaceKey);
|
||||
|
||||
let html = '<div style="color:#fff;font-size:13px;">';
|
||||
|
||||
// Доступные рецепты — показываем только те, для которых есть ресурсы
|
||||
for (let i = 0; i < SMELTING_RECIPES.length; i++) {
|
||||
const recipe = SMELTING_RECIPES[i];
|
||||
const haveCount = state.inv[recipe.in] || 0;
|
||||
const canSmelt = haveCount >= recipe.qty;
|
||||
|
||||
// Иконка результата
|
||||
const outDef = BLOCKS[recipe.out];
|
||||
const outItem = ITEMS[recipe.out];
|
||||
const iconStr = outItem ? outItem.icon : (outDef ? '🧱' : '❓');
|
||||
const nameStr = outItem ? outItem.n : (outDef ? outDef.n : recipe.out);
|
||||
const inItem = ITEMS[recipe.in];
|
||||
const inDef = BLOCKS[recipe.in];
|
||||
const inName = inItem ? inItem.n : (inDef ? inDef.n : recipe.in);
|
||||
|
||||
html += `<div class="recipe">`;
|
||||
html += `<div class="ricon" style="font-size:24px;display:flex;align-items:center;justify-content:center;">${iconStr}</div>`;
|
||||
html += `<div class="rinfo">`;
|
||||
html += `<div class="rname">${nameStr}</div>`;
|
||||
html += `<div class="rcost">${inName} x${recipe.qty} (есть: ${haveCount}) • ${recipe.time}с</div>`;
|
||||
html += `</div>`;
|
||||
html += `<button class="rcraft" onclick="window._smelt(${i})" ${canSmelt ? '' : 'disabled'}>🔥</button>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Текущий прогресс
|
||||
if (active) {
|
||||
const pct = Math.min(100, Math.floor((active.progress / active.recipe.time) * 100));
|
||||
html += `<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.1);border-radius:8px;">`;
|
||||
html += `<div style="color:#f39c12;font-weight:900;">🔥 Обжиг: ${pct}%</div>`;
|
||||
html += `<div style="background:#333;height:8px;border-radius:4px;margin-top:4px;">`;
|
||||
html += `<div style="background:#f39c12;height:8px;border-radius:4px;width:${pct}%;"></div>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
furnaceContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Глобальная функция для кнопки обжига
|
||||
window._smelt = (recipeIdx) => {
|
||||
if (!state.currentFurnaceKey) return;
|
||||
const recipe = SMELTING_RECIPES[recipeIdx];
|
||||
if ((state.inv[recipe.in] || 0) < recipe.qty) return;
|
||||
|
||||
// Уже обжигаем в этой печи?
|
||||
if (state.activeFurnaces.has(state.currentFurnaceKey)) return;
|
||||
|
||||
// Забираем ресурсы
|
||||
state.inv[recipe.in] -= recipe.qty;
|
||||
|
||||
// Запускаем обжиг
|
||||
state.activeFurnaces.set(state.currentFurnaceKey, {
|
||||
recipe: recipe,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
playSound('fire');
|
||||
rebuildHotbar();
|
||||
renderFurnaceUI();
|
||||
};
|
||||
|
||||
// Тик печей — вызывается в главном цикле
|
||||
export function tickFurnaces(dt) {
|
||||
for (const [key, furnace] of state.activeFurnaces) {
|
||||
furnace.progress += dt;
|
||||
if (furnace.progress >= furnace.recipe.time) {
|
||||
// Обжиг завершён — выдаём результат
|
||||
const outItem = furnace.recipe.out;
|
||||
if (ITEMS[outItem]) {
|
||||
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
|
||||
} else if (BLOCKS[outItem]) {
|
||||
state.inv[outItem] = (state.inv[outItem] || 0) + furnace.recipe.outQty;
|
||||
}
|
||||
playSound('stone_build');
|
||||
state.activeFurnaces.delete(key);
|
||||
|
||||
// Если эта печь открыта — обновляем UI
|
||||
if (key === state.currentFurnaceKey) {
|
||||
renderFurnaceUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// Hotbar UI
|
||||
import { state } from '../core/state.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { ITEMS } from '../data/items.js';
|
||||
import { TOOLS } from '../data/tools.js';
|
||||
import { tex } from '../render/textures.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
|
||||
export function rebuildHotbar() {
|
||||
const hotbarEl = state.hotbarEl;
|
||||
const inv = state.inv;
|
||||
const selected = state.selected;
|
||||
const recentItems = state.recentItems;
|
||||
const toolDurability = state.toolDurability;
|
||||
|
||||
hotbarEl.innerHTML = '';
|
||||
|
||||
// Показываем последние 5 выбранных предметов (если они есть в инвентаре)
|
||||
const items = recentItems.filter(id => inv[id] > 0).slice(0, 5);
|
||||
|
||||
for (const id of items) {
|
||||
const s = document.createElement('div');
|
||||
s.className = 'slot' + (id === selected ? ' sel' : '');
|
||||
if (BLOCKS[id]) {
|
||||
s.style.backgroundImage = `url(${tex[id].toDataURL()})`;
|
||||
s.style.backgroundSize = 'cover';
|
||||
} else if (ITEMS[id]) {
|
||||
s.textContent = ITEMS[id].icon;
|
||||
} else if (TOOLS[id]) {
|
||||
s.textContent = TOOLS[id].icon;
|
||||
} else if (id === 'iron_armor') {
|
||||
s.textContent = '🛡️';
|
||||
s.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
|
||||
}
|
||||
const c = document.createElement('div');
|
||||
c.className = 'count';
|
||||
c.textContent = inv[id];
|
||||
s.appendChild(c);
|
||||
s.onclick = () => {
|
||||
playSound('click'); // Звук клика по инвентарю
|
||||
state.selected = id;
|
||||
// Обновляем список последних предметов
|
||||
state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть
|
||||
state.recentItems.unshift(id); // Добавляем в начало
|
||||
state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5
|
||||
rebuildHotbar();
|
||||
};
|
||||
|
||||
// Показываем индикатор надетой брони
|
||||
if (id === 'iron_armor' && state.player.equippedArmor === 'iron_armor') {
|
||||
const equipped = document.createElement('div');
|
||||
equipped.className = 'equipped-indicator';
|
||||
equipped.textContent = '✓';
|
||||
s.appendChild(equipped);
|
||||
}
|
||||
|
||||
// Durability bar для инструментов
|
||||
if (TOOLS[id] && inv[id] > 0) {
|
||||
// Находим текущую прочность
|
||||
let curDur = 0, maxDur = TOOLS[id].durability;
|
||||
for (const [tid, dur] of toolDurability) {
|
||||
if (dur.type === id) {
|
||||
curDur = dur.current;
|
||||
maxDur = dur.max;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (maxDur > 0) {
|
||||
const bar = document.createElement('div');
|
||||
bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:3px;background:#333;border-radius:0 0 6px 6px;overflow:hidden;`;
|
||||
const fill = document.createElement('div');
|
||||
const pct = curDur / maxDur;
|
||||
const color = pct > 0.5 ? '#2ecc71' : pct > 0.25 ? '#f39c12' : '#e74c3c';
|
||||
fill.style.cssText = `width:${pct * 100}%;height:100%;background:${color};`;
|
||||
bar.appendChild(fill);
|
||||
s.appendChild(bar);
|
||||
}
|
||||
}
|
||||
hotbarEl.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// Inventory UI
|
||||
import { state } from '../core/state.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { ITEMS } from '../data/items.js';
|
||||
import { TOOLS } from '../data/tools.js';
|
||||
import { tex } from '../render/textures.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { rebuildHotbar } from './hotbar.js';
|
||||
|
||||
export function renderInventory() {
|
||||
const inventoryGrid = state.inventoryGrid;
|
||||
const inv = state.inv;
|
||||
const selected = state.selected;
|
||||
inventoryGrid.innerHTML = '';
|
||||
|
||||
// Создаём сетку инвентаря 7x3
|
||||
const items = Object.keys(inv).filter(id => inv[id] > 0);
|
||||
|
||||
// Добавляем пустые слоты для полной сетки
|
||||
for (let i = 0; i < 21; i++) {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'inv-slot' + (i < items.length && items[i] === selected ? ' sel' : '');
|
||||
|
||||
if (i < items.length) {
|
||||
const id = items[i];
|
||||
if (BLOCKS[id]) {
|
||||
slot.style.backgroundImage = `url(${tex[id].toDataURL()})`;
|
||||
slot.style.backgroundSize = 'cover';
|
||||
} else if (ITEMS[id]) {
|
||||
slot.textContent = ITEMS[id].icon;
|
||||
} else if (TOOLS[id]) {
|
||||
slot.textContent = TOOLS[id].icon;
|
||||
} else if (id === 'iron_armor') {
|
||||
slot.textContent = '🛡️';
|
||||
slot.style.background = 'linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%)';
|
||||
}
|
||||
|
||||
const count = document.createElement('div');
|
||||
count.className = 'inv-count';
|
||||
count.textContent = inv[id];
|
||||
slot.appendChild(count);
|
||||
|
||||
slot.onclick = () => {
|
||||
playSound('click'); // Звук клика по инвентарю
|
||||
state.selected = id;
|
||||
// Обновляем список последних предметов
|
||||
state.recentItems = state.recentItems.filter(item => item !== id); // Удаляем если уже есть
|
||||
state.recentItems.unshift(id); // Добавляем в начало
|
||||
state.recentItems = state.recentItems.slice(0, 5); // Оставляем только 5
|
||||
rebuildHotbar();
|
||||
renderInventory();
|
||||
};
|
||||
|
||||
// Двойной клик для надевания брони
|
||||
slot.ondblclick = () => {
|
||||
if (id === 'iron_armor' && inv.iron_armor > 0) {
|
||||
// Если уже надета броня - снимаем её
|
||||
if (state.player.equippedArmor === 'iron_armor') {
|
||||
state.player.equippedArmor = null;
|
||||
state.player.armor = 0;
|
||||
console.log('[ARMOR] Iron armor unequipped');
|
||||
} else {
|
||||
// Надеваем броню
|
||||
state.player.equippedArmor = 'iron_armor';
|
||||
state.player.armor = BLOCKS['iron_armor'].armor;
|
||||
console.log('[ARMOR] Iron armor equipped - armor:', state.player.armor);
|
||||
}
|
||||
playSound('click');
|
||||
renderInventory();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
inventoryGrid.appendChild(slot);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// ==================== МИНИКАРТА ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { getBlock } from '../world/world-storage.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { isNight } from '../entities/mob-ai.js';
|
||||
|
||||
const minimapWrap = document.getElementById('minimapWrap');
|
||||
const minimapCanvas = document.getElementById('minimap');
|
||||
const minimapCtx = minimapCanvas.getContext('2d');
|
||||
|
||||
// Цвета блоков для миникарты (по 1 пикселю на блок)
|
||||
const MINIMAP_COLORS = {
|
||||
grass: '#4a8c2a', dirt: '#6b3410', stone: '#6a6a6a', sand: '#d4b84a',
|
||||
gravel: '#888', clay: '#5a9ad4', wood: '#a63d00', planks: '#c46a10',
|
||||
leaves: '#2a8a3a', glass: '#aaddee', water: '#2980b9', coal: '#1a1a2a',
|
||||
copper_ore: '#a05535', iron_ore: '#b0b0b0', gold_ore: '#d4a017',
|
||||
diamond_ore: '#0090d0', brick: '#8a2010', tnt: '#c02020',
|
||||
campfire: '#d08020', torch: '#d0a020', bedrock: '#1a1a1a',
|
||||
flower: '#d03050', bed: '#c03060', ladder: '#a04000', boat: '#6b3410'
|
||||
};
|
||||
|
||||
export function initMinimap() {
|
||||
document.getElementById('mapToggle').onclick = () => {
|
||||
playSound('click');
|
||||
state.minimapOpen = !state.minimapOpen;
|
||||
minimapWrap.style.display = state.minimapOpen ? 'block' : 'none';
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMinimap() {
|
||||
if (!state.minimapOpen) return;
|
||||
const mW = minimapCanvas.width;
|
||||
const mH = minimapCanvas.height;
|
||||
const scale = 2; // пикселей на блок
|
||||
const TILE = state.TILE;
|
||||
const player = state.player;
|
||||
|
||||
// Область карты — центрирована на игроке
|
||||
const pGX = Math.floor(player.x / TILE);
|
||||
const pGY = Math.floor(player.y / TILE);
|
||||
const viewW = Math.floor(mW / scale);
|
||||
const viewH = Math.floor(mH / scale);
|
||||
const startGX = pGX - Math.floor(viewW / 2);
|
||||
const startGY = pGY - Math.floor(viewH / 2);
|
||||
|
||||
// Очищаем
|
||||
minimapCtx.fillStyle = isNight() ? '#070816' : '#87CEEB';
|
||||
minimapCtx.fillRect(0, 0, mW, mH);
|
||||
|
||||
// Рисуем блоки
|
||||
const imgData = minimapCtx.createImageData(mW, mH);
|
||||
const data = imgData.data;
|
||||
|
||||
for (let dx = 0; dx < viewW; dx++) {
|
||||
for (let dy = 0; dy < viewH; dy++) {
|
||||
const gx = startGX + dx;
|
||||
const gy = startGY + dy;
|
||||
const b = getBlock(gx, gy);
|
||||
if (!b || b.dead || b.t === 'air') continue;
|
||||
|
||||
const color = MINIMAP_COLORS[b.t];
|
||||
if (!color) continue;
|
||||
|
||||
// Парсим hex цвет
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const bl = parseInt(color.slice(5, 7), 16);
|
||||
|
||||
// Заполняем scale x scale пикселей
|
||||
for (let sx = 0; sx < scale; sx++) {
|
||||
for (let sy = 0; sy < scale; sy++) {
|
||||
const px = dx * scale + sx;
|
||||
const py = dy * scale + sy;
|
||||
if (px >= mW || py >= mH) continue;
|
||||
const idx = (py * mW + px) * 4;
|
||||
data[idx] = r;
|
||||
data[idx + 1] = g;
|
||||
data[idx + 2] = bl;
|
||||
data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
minimapCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
// Игрок — белый пиксель по центру
|
||||
minimapCtx.fillStyle = '#fff';
|
||||
minimapCtx.fillRect(Math.floor(mW / 2) - 2, Math.floor(mH / 2) - 2, 4, 4);
|
||||
|
||||
// Другие игроки — жёлтые точки
|
||||
for (const [sid, p] of state.otherPlayers) {
|
||||
const dx = Math.floor(p.x / TILE) - startGX;
|
||||
const dy = Math.floor(p.y / TILE) - startGY;
|
||||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||||
minimapCtx.fillStyle = '#f1c40f';
|
||||
minimapCtx.fillRect(dx * scale - 1, dy * scale - 1, 3, 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Мобы — красные (враждебные) / зелёные (животные)
|
||||
const allMobsForMap = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs;
|
||||
for (const m of allMobsForMap) {
|
||||
const dx = Math.floor(m.x / TILE) - startGX;
|
||||
const dy = Math.floor(m.y / TILE) - startGY;
|
||||
if (dx >= 0 && dx < viewW && dy >= 0 && dy < viewH) {
|
||||
const hostile = m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton';
|
||||
minimapCtx.fillStyle = hostile ? '#e74c3c' : '#2ecc71';
|
||||
minimapCtx.fillRect(dx * scale, dy * scale, 2, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// ==================== RESPAWN ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { loadGame, applySave } from '../game/save.js';
|
||||
|
||||
export function initRespawn() {
|
||||
document.getElementById('respawnBtn').onclick = async () => {
|
||||
playSound('click'); // Звук клика по кнопке
|
||||
|
||||
console.log('=== RESPAWN CLICKED ===');
|
||||
console.log('isMultiplayer:', state.isMultiplayer);
|
||||
console.log('otherPlayers.size:', state.otherPlayers.size);
|
||||
console.log('player.hp before respawn:', state.player.hp);
|
||||
|
||||
const player = state.player;
|
||||
const deathEl = state.deathEl;
|
||||
|
||||
// В мультиплеере не загружаем сохранение, а возрождаемся в начальной точке
|
||||
if (state.isMultiplayer && state.otherPlayers.size > 0) {
|
||||
console.log('Мультиплеер режим - возрождение в начальной точке');
|
||||
player.hp = 100;
|
||||
player.hunger = 100;
|
||||
player.o2 = 100;
|
||||
player.vx = player.vy = 0;
|
||||
player.invuln = 0;
|
||||
player.x = state.spawnPoint.x;
|
||||
player.y = state.spawnPoint.y;
|
||||
player.fallStartY = player.y;
|
||||
console.log('Возрождение в начальной точке, HP:', player.hp);
|
||||
} else {
|
||||
console.log('Одиночный режим - загружаем последнее сохранение');
|
||||
// Одиночный режим - загружаем последнее сохранение
|
||||
const loadedSave = await loadGame();
|
||||
if (loadedSave) {
|
||||
await applySave(loadedSave);
|
||||
console.log('Загружено последнее сохранение после смерти, final HP:', player.hp);
|
||||
} else {
|
||||
// Если сохранения нет, возрождаемся в начальной точке
|
||||
player.hp = 100;
|
||||
player.hunger = 100;
|
||||
player.o2 = 100;
|
||||
player.vx = player.vy = 0;
|
||||
player.invuln = 0;
|
||||
player.x = state.spawnPoint.x;
|
||||
player.y = state.spawnPoint.y;
|
||||
player.fallStartY = player.y;
|
||||
console.log('Возрождение в начальной точке, HP:', player.hp);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('player.hp after respawn logic:', player.hp);
|
||||
console.log('Hiding death screen...');
|
||||
deathEl.style.display = 'none';
|
||||
console.log('=== RESPAWN END ===');
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// ==================== КНОПКИ СОХРАНЕНИЯ / СБРОСА ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { saveGame } from '../game/save.js';
|
||||
|
||||
export function initSaveControls() {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
saveBtn.onclick = () => {
|
||||
playSound('click');
|
||||
saveGame();
|
||||
alert('Игра сохранена!');
|
||||
};
|
||||
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
resetBtn.onclick = () => {
|
||||
if (confirm('Вы уверены, что хотите удалить сохранение и начать новую игру?')) {
|
||||
playSound('click');
|
||||
|
||||
// Удаляем сохранение из localStorage
|
||||
try {
|
||||
localStorage.removeItem(state.SAVE_KEY);
|
||||
console.log('Сохранение удалено из localStorage');
|
||||
} catch (e) {
|
||||
console.warn('Ошибка удаления сохранения:', e);
|
||||
}
|
||||
|
||||
// Сбрасываем in-memory сохранение
|
||||
state.inMemorySave = null;
|
||||
|
||||
// Генерируем новый worldId
|
||||
state.worldId = Math.random().toString(36).substring(2, 10);
|
||||
console.log('Новый worldId после сброса:', state.worldId);
|
||||
|
||||
// Обновляем URL
|
||||
try {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('world', state.worldId);
|
||||
const newUrlString = newUrl.toString();
|
||||
|
||||
if (typeof window.history !== 'undefined' && typeof window.history.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', newUrlString);
|
||||
console.log('URL обновлён:', newUrlString);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка обновления URL:', e);
|
||||
}
|
||||
|
||||
// Перезагружаем страницу
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Показываем кнопку сохранения только если играем одни
|
||||
export function updateSaveButtonVisibility() {
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
if (state.isMultiplayer && state.otherPlayers.size > 0) {
|
||||
saveBtn.style.display = 'none';
|
||||
} else {
|
||||
saveBtn.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// ==================== ПОДЕЛИТЬСЯ МИРОМ ====================
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
export function initShare() {
|
||||
// Обработчик клика на worldId для копирования ссылки
|
||||
document.getElementById('worldId').onclick = () => {
|
||||
const shareUrl = new URL(window.location.href);
|
||||
shareUrl.searchParams.set('world', state.worldId);
|
||||
const shareUrlString = shareUrl.toString();
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(shareUrlString).then(() => {
|
||||
alert('Ссылка скопирована!');
|
||||
}).catch(() => {
|
||||
alert('Ссылка на мир:\n' + shareUrlString);
|
||||
});
|
||||
} else {
|
||||
alert('Ссылка на мир:\n' + shareUrlString);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function shareWorld() {
|
||||
const shareUrl = new URL(window.location.href);
|
||||
shareUrl.searchParams.set('world', state.worldId);
|
||||
const shareUrlString = shareUrl.toString();
|
||||
|
||||
// Копируем в буфер обмена
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(shareUrlString).then(() => {
|
||||
alert('Ссылка скопирована!');
|
||||
}).catch(() => {
|
||||
alert('Ссылка на мир:\n' + shareUrlString);
|
||||
});
|
||||
} else {
|
||||
alert('Ссылка на мир:\n' + shareUrlString);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { TILE, SEA_GY, BEDROCK_GY, GEN_MARGIN_X } from '../core/constants.js';
|
||||
import { state } from '../core/state.js';
|
||||
import { grid, blocks, setBlock, getBlock, k } from './world-storage.js';
|
||||
|
||||
// Генерация (по X, на всю глубину до bedrock)
|
||||
const generated = state.generated; // Set of gx already generated
|
||||
|
||||
function surfaceGyAt(gx) {
|
||||
// базовая поверхность выше уровня воды с вариациями + "горы"
|
||||
// Используем seed для детерминированной генерации
|
||||
// Увеличили амплитуду и добавили больше частот для разнообразия
|
||||
const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; // крупные горы
|
||||
const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12; // средние горы
|
||||
const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6; // мелкие холмы
|
||||
const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4; // детали
|
||||
const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5; // дополнительные вариации
|
||||
const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше
|
||||
return h;
|
||||
}
|
||||
|
||||
function genColumn(gx) {
|
||||
if (generated.has(gx)) return;
|
||||
generated.add(gx);
|
||||
|
||||
const sgy = surfaceGyAt(gx);
|
||||
|
||||
// вода (если поверхность ниже уровня моря => sgy > SEA_GY)
|
||||
if (sgy > SEA_GY) {
|
||||
for (let gy = SEA_GY; gy < sgy; gy++) {
|
||||
setBlock(gx, gy, 'water');
|
||||
}
|
||||
// пляж
|
||||
setBlock(gx, sgy, 'sand');
|
||||
} else {
|
||||
// верхний блок: снег на высоких точках
|
||||
if (sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone');
|
||||
else setBlock(gx, sgy, 'grass');
|
||||
}
|
||||
|
||||
// подповерхностные слои
|
||||
for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) {
|
||||
if (gy === BEDROCK_GY) {
|
||||
setBlock(gx, gy, 'bedrock');
|
||||
continue;
|
||||
}
|
||||
|
||||
let t = 'stone';
|
||||
|
||||
// ближе к поверхности
|
||||
if (gy <= sgy + 3) t = 'dirt';
|
||||
|
||||
// биомы/материалы
|
||||
if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = 'clay';
|
||||
if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = 'gravel';
|
||||
|
||||
// руды: чем глубже, тем интереснее
|
||||
const depth = gy - sgy;
|
||||
const r = seededRandom(gx, gy);
|
||||
if (t === 'stone') {
|
||||
if (r < 0.06) t = 'coal';
|
||||
else if (r < 0.10) t = 'copper_ore';
|
||||
else if (r < 0.13) t = 'iron_ore';
|
||||
else if (depth > 40 && r < 0.145) t = 'gold_ore';
|
||||
else if (depth > 70 && r < 0.152) t = 'diamond_ore';
|
||||
}
|
||||
|
||||
setBlock(gx, gy, t);
|
||||
}
|
||||
|
||||
// Деревья и цветы (только на траве, и не в воде)
|
||||
const top = getBlock(gx, sgy);
|
||||
if (top && top.t === 'grass') {
|
||||
if (seededRandom(gx, sgy - 1) < 0.10) {
|
||||
setBlock(gx, sgy - 1, 'flower');
|
||||
}
|
||||
if (seededRandom(gx, sgy - 2) < 0.12) {
|
||||
// простое дерево
|
||||
setBlock(gx, sgy - 1, 'wood');
|
||||
setBlock(gx, sgy - 2, 'wood');
|
||||
setBlock(gx, sgy - 3, 'leaves');
|
||||
setBlock(gx - 1, sgy - 3, 'leaves');
|
||||
setBlock(gx + 1, sgy - 3, 'leaves');
|
||||
}
|
||||
}
|
||||
|
||||
// Применяем серверные оверрайды для этой колонны
|
||||
const colPrefix = gx + ',';
|
||||
for (const [key, ov] of state.serverOverrides) {
|
||||
if (!key.startsWith(colPrefix)) continue;
|
||||
if (ov.op === 'remove') {
|
||||
const b = grid.get(key);
|
||||
if (b) { grid.delete(key); b.dead = true; }
|
||||
} else if (ov.op === 'set') {
|
||||
if (!grid.has(key)) {
|
||||
const gy = parseInt(key.split(',')[1]);
|
||||
const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
|
||||
grid.set(key, nb);
|
||||
blocks.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Перегенерация видимых чанков (используется при загрузке сохранения)
|
||||
function regenerateVisibleChunks() {
|
||||
const gx0 = Math.floor(state.camX / TILE);
|
||||
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
|
||||
// Принудительно перегенерируем колонну
|
||||
generated.delete(gx);
|
||||
genColumn(gx);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureGenAroundCamera() {
|
||||
const gx0 = Math.floor(state.camX / TILE);
|
||||
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
|
||||
genColumn(gx);
|
||||
}
|
||||
}
|
||||
|
||||
function seededRandom(gx, gy) {
|
||||
const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
|
||||
return n - Math.floor(n);
|
||||
}
|
||||
|
||||
export { generated, surfaceGyAt, genColumn, regenerateVisibleChunks, ensureGenAroundCamera, seededRandom };
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { TILE } from '../core/constants.js';
|
||||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { state } from '../core/state.js';
|
||||
import { getBlock, removeBlock, k } from './world-storage.js';
|
||||
import { calculateDamage } from '../entities/player.js';
|
||||
import { playSound } from '../audio/sound-engine.js';
|
||||
import { spawnExplosion } from '../render/particles.js';
|
||||
import { rebuildHotbar } from '../ui/hotbar.js';
|
||||
|
||||
// TNT логика: цепь + усиление
|
||||
function activateTNT(b, fuse = 3.2) {
|
||||
if (b.dead) return;
|
||||
if (b.active) return;
|
||||
b.active = true;
|
||||
b.fuse = fuse;
|
||||
state.activeTNT.add(k(b.gx, b.gy));
|
||||
}
|
||||
|
||||
function explodeAt(gx, gy) {
|
||||
const center = getBlock(gx, gy);
|
||||
if (!center) return;
|
||||
|
||||
// усиление: считаем сколько TNT рядом (в радиусе 2) и активируем их «почти сразу»
|
||||
let bonus = 0;
|
||||
for (let x = gx - 2; x <= gx + 2; x++) {
|
||||
for (let y = gy - 2; y <= gy + 2; y++) {
|
||||
const b = getBlock(x, y);
|
||||
if (b && !b.dead && b.t === 'tnt' && !(x === gx && y === gy)) {
|
||||
bonus += 0.8;
|
||||
activateTNT(b, 0.12); // цепь
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const power = 1 + bonus; // условная мощность
|
||||
const radius = 3.2 + bonus * 0.7; // радиус разрушения в тайлах
|
||||
const dmgR = 150 + bonus * 60; // радиус урона в пикселях
|
||||
|
||||
removeBlock(gx, gy);
|
||||
state.activeTNT.delete(k(gx, gy));
|
||||
playSound('explode1'); // Звук взрыва
|
||||
spawnExplosion(gx * TILE + TILE / 2, gy * TILE + TILE / 2, power);
|
||||
|
||||
for (let x = Math.floor(gx - radius); x <= Math.ceil(gx + radius); x++) {
|
||||
for (let y = Math.floor(gy - radius); y <= Math.ceil(gy + radius); y++) {
|
||||
const d = Math.hypot(x - gx, y - gy);
|
||||
if (d > radius) continue;
|
||||
const b = getBlock(x, y);
|
||||
if (!b || b.dead) continue;
|
||||
if (BLOCKS[b.t].fluid) continue;
|
||||
if (BLOCKS[b.t].unbreakable) continue;
|
||||
if (b.t === 'tnt') { activateTNT(b, 0.12); continue; }
|
||||
removeBlock(x, y);
|
||||
if (state.inv[b.t] !== undefined && Math.random() < 0.20) state.inv[b.t]++; // немного дропа
|
||||
}
|
||||
}
|
||||
rebuildHotbar();
|
||||
|
||||
// урон
|
||||
const hurt = (e) => {
|
||||
const dx = (e.x + e.w / 2) - (gx * TILE + TILE / 2);
|
||||
const dy = (e.y + e.h / 2) - (gy * TILE + TILE / 2);
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < dmgR) {
|
||||
const dmg = (dmgR - dist) * 0.06 * power;
|
||||
if (e === state.player) {
|
||||
const actualDamage = calculateDamage(dmg);
|
||||
state.player.hp -= actualDamage;
|
||||
} else {
|
||||
e.hp -= dmg;
|
||||
}
|
||||
e.vx += (dx / dist || 0) * 600;
|
||||
e.vy -= 320;
|
||||
}
|
||||
};
|
||||
hurt(state.player);
|
||||
state.mobs.forEach(hurt);
|
||||
}
|
||||
|
||||
export { activateTNT, explodeAt };
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { TILE, SEA_GY } from '../core/constants.js';
|
||||
import { state } from '../core/state.js';
|
||||
import { W, H } from '../core/canvas.js';
|
||||
import { grid, blocks, k, isSolid } from './world-storage.js';
|
||||
|
||||
// Физика жидкости
|
||||
const waterUpdateQueue = new Set();
|
||||
let waterUpdateTimer = 0;
|
||||
const WATER_UPDATE_INTERVAL = 0.05; // Обновляем воду каждые 0.05 секунды
|
||||
|
||||
function updateWaterPhysics(dt) {
|
||||
waterUpdateTimer += dt;
|
||||
if (waterUpdateTimer < WATER_UPDATE_INTERVAL) return;
|
||||
waterUpdateTimer = 0;
|
||||
|
||||
// Ограничиваем количество водных блоков для обработки (оптимизация)
|
||||
const MAX_WATER_BLOCKS_PER_UPDATE = 50;
|
||||
let processedCount = 0;
|
||||
|
||||
// Собираем только видимые водные блоки в очередь (оптимизация)
|
||||
waterUpdateQueue.clear();
|
||||
const minGX = Math.floor(state.camX / TILE) - 10;
|
||||
const maxGX = Math.floor((state.camX + W) / TILE) + 10;
|
||||
const minGY = Math.floor(state.camY / TILE) - 10;
|
||||
const maxGY = Math.floor((state.camY + H) / TILE) + 10;
|
||||
|
||||
for (const b of blocks) {
|
||||
if (processedCount >= MAX_WATER_BLOCKS_PER_UPDATE) break;
|
||||
if (!b.dead && b.t === 'water' &&
|
||||
b.gx >= minGX && b.gx <= maxGX &&
|
||||
b.gy >= minGY && b.gy <= maxGY) {
|
||||
waterUpdateQueue.add(k(b.gx, b.gy));
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем воду с ограничением глубины распространения
|
||||
const processed = new Set();
|
||||
const toAdd = [];
|
||||
const MAX_WATER_DEPTH = 20; // Максимальная глубина распространения воды
|
||||
|
||||
for (const key of waterUpdateQueue) {
|
||||
if (processed.has(key)) continue;
|
||||
const b = grid.get(key);
|
||||
if (!b || b.dead) continue;
|
||||
processed.add(key);
|
||||
|
||||
const gx = b.gx;
|
||||
const gy = b.gy;
|
||||
|
||||
// Проверяем глубину - не распространяем воду слишком глубоко
|
||||
if (gy > SEA_GY + MAX_WATER_DEPTH) continue;
|
||||
|
||||
// Проверяем, можно ли воде упасть вниз
|
||||
const belowKey = k(gx, gy + 1);
|
||||
const below = grid.get(belowKey);
|
||||
|
||||
// Внизу пусто - вода создаёт новый блок внизу (но не удаляется сверху)
|
||||
if (!below || below.dead) {
|
||||
// Ограничиваем создание новых водных блоков
|
||||
if (toAdd.length < 20) { // Максимум 20 новых блоков за обновление
|
||||
toAdd.push({ gx, gy: gy + 1, t: 'water' });
|
||||
processed.add(belowKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Если внизу не вода и не твёрдый блок - вода может течь вниз
|
||||
if (!isSolid(gx, gy + 1) && below && below.t !== 'water') {
|
||||
if (toAdd.length < 20) {
|
||||
toAdd.push({ gx, gy: gy + 1, t: 'water' });
|
||||
processed.add(belowKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Если внизу твёрдый блок или вода - вода растекается горизонтально
|
||||
// Проверяем левую сторону
|
||||
const leftKey = k(gx - 1, gy);
|
||||
const left = grid.get(leftKey);
|
||||
if (!left || left.dead) {
|
||||
if (toAdd.length < 20) {
|
||||
toAdd.push({ gx: gx - 1, gy, t: 'water' });
|
||||
processed.add(leftKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем правую сторону
|
||||
const rightKey = k(gx + 1, gy);
|
||||
const right = grid.get(rightKey);
|
||||
if (!right || right.dead) {
|
||||
if (toAdd.length < 20) {
|
||||
toAdd.push({ gx: gx + 1, gy, t: 'water' });
|
||||
processed.add(rightKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Применяем изменения (только добавляем новые блоки)
|
||||
for (const newData of toAdd) {
|
||||
const key = k(newData.gx, newData.gy);
|
||||
if (!grid.has(key)) {
|
||||
const b = {
|
||||
gx: newData.gx,
|
||||
gy: newData.gy,
|
||||
t: newData.t,
|
||||
dead: false,
|
||||
active: false,
|
||||
fuse: 0
|
||||
};
|
||||
grid.set(key, b);
|
||||
blocks.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем мёртвые блоки из массива
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
if (blocks[i].dead) {
|
||||
blocks.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { updateWaterPhysics };
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// ==================== ПОГОДА ====================
|
||||
import { state } from '../core/state.js';
|
||||
import { isNight } from '../entities/mob-ai.js';
|
||||
|
||||
export function updateWeather(dt) {
|
||||
state.weatherTimer += dt;
|
||||
if (state.weatherTimer >= state.weatherChangeInterval) {
|
||||
state.weatherTimer = 0;
|
||||
state.weatherChangeInterval = 60 + Math.random() * 120;
|
||||
// Выбираем новую погоду: 40% дождь днём, 25% дождь ночью, остальное ясно
|
||||
const nightChance = isNight() ? 0.25 : 0.40;
|
||||
state.isRaining = Math.random() < nightChance;
|
||||
}
|
||||
// Плавная интерполяция интенсивности
|
||||
const target = state.isRaining ? (0.4 + Math.random() * 0.01) : 0;
|
||||
state.rainIntensity += (target - state.rainIntensity) * dt * 0.5;
|
||||
if (state.rainIntensity < 0.01) state.rainIntensity = 0;
|
||||
}
|
||||
|
||||
export function updateRain(dt) {
|
||||
if (!state.isRaining || state.rainIntensity < 0.01) {
|
||||
state.raindrops.length = 0;
|
||||
return;
|
||||
}
|
||||
// Спавн капель
|
||||
const spawnRate = Math.floor(state.rainIntensity * 60 * dt); // ~60 капель/сек при интенсити=1
|
||||
for (let i = 0; i < spawnRate && state.raindrops.length < state.MAX_RAINDROPS; i++) {
|
||||
state.raindrops.push({
|
||||
x: state.camX + Math.random() * state.W,
|
||||
y: state.camY - 20,
|
||||
vy: 400 + Math.random() * 200,
|
||||
len: 8 + Math.random() * 12
|
||||
});
|
||||
}
|
||||
// Обновление
|
||||
for (let i = state.raindrops.length - 1; i >= 0; i--) {
|
||||
const d = state.raindrops[i];
|
||||
d.y += d.vy * dt;
|
||||
d.x -= 30 * dt; // лёгкий ветер
|
||||
if (d.y > state.camY + state.H + 20) {
|
||||
state.raindrops.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawRain() {
|
||||
if (state.raindrops.length === 0) return;
|
||||
const ctx = state.ctx;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(174,194,224,0.5)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
for (const d of state.raindrops) {
|
||||
ctx.moveTo(d.x, d.y);
|
||||
ctx.lineTo(d.x - 3, d.y + d.len);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { BLOCKS } from '../data/blocks.js';
|
||||
import { state } from '../core/state.js';
|
||||
|
||||
// Мир-хранилище
|
||||
const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
|
||||
const blocks = []; // для рендера/перебора видимых
|
||||
|
||||
function k(gx, gy) { return gx + ',' + gy; }
|
||||
|
||||
function getBlock(gx, gy) { return grid.get(k(gx, gy)); }
|
||||
|
||||
function hasBlock(gx, gy) { return grid.has(k(gx, gy)); }
|
||||
|
||||
function isSolid(gx, gy) {
|
||||
const b = getBlock(gx, gy);
|
||||
if (!b || b.dead) return false;
|
||||
const def = BLOCKS[b.t];
|
||||
return !!def.solid && !def.fluid && !def.decor;
|
||||
}
|
||||
|
||||
function setBlock(gx, gy, t, isPlayerPlaced = false) {
|
||||
const key = k(gx, gy);
|
||||
if (grid.has(key)) return false;
|
||||
const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
|
||||
grid.set(key, b);
|
||||
blocks.push(b);
|
||||
|
||||
// Отслеживаем блоки, установленные игроком
|
||||
if (isPlayerPlaced) {
|
||||
state.placedBlocks.push({ gx, gy, t });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeBlock(gx, gy) {
|
||||
const key = k(gx, gy);
|
||||
const b = grid.get(key);
|
||||
if (!b) return null;
|
||||
if (BLOCKS[b.t].unbreakable) return null;
|
||||
grid.delete(key);
|
||||
b.dead = true;
|
||||
|
||||
// Отслеживаем удалённые блоки
|
||||
const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
|
||||
if (wasPlayerPlaced) {
|
||||
// Удаляем из placedBlocks
|
||||
state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
|
||||
} else {
|
||||
// Это природный блок - добавляем в removedBlocks
|
||||
state.removedBlocks.push({ gx, gy });
|
||||
}
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
export { grid, blocks, k, getBlock, hasBlock, isSolid, setBlock, removeBlock };
|
||||
13
style.css
13
style.css
|
|
@ -91,3 +91,16 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue