feat: XP/level system, mob loot drops, level-up popup

This commit is contained in:
Mk 2026-05-26 13:29:26 +00:00
parent edb08094db
commit 2ebb457fc5
3 changed files with 3920 additions and 18 deletions

166
game.js
View File

@ -190,6 +190,13 @@ function customConfirm(msg, onYes) {
// Показываем в UI
worldIdEl.textContent = worldId;
// XP/Level display
const lvXpNext = xpForLevel(player.level + 1);
const lvXpCur = xpForLevel(player.level);
const xpInLevel = player.xp - lvXpCur;
const xpNeeded = lvXpNext - lvXpCur;
document.getElementById('xplevel').textContent = player.level;
document.getElementById('xpbar').textContent = xpInLevel + '/' + xpNeeded;
multiplayerStatus.style.display = 'block';
});
@ -378,11 +385,8 @@ function customConfirm(msg, onYes) {
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);
@ -657,6 +661,10 @@ function customConfirm(msg, onYes) {
meat: { n:'Сырое мясо', icon:'🥩', food:15 },
cooked: { n:'Жареное мясо', icon:'🍖', food:45 },
arrow: { n:'Стрела', icon:'➡️', stack:64 },
chicken_meat: { n:'Курица', icon:'🍗', food:12, stack:64 },
feather: { n:'Перо', icon:'🪶', stack:64 },
bone: { n:'Кость', icon:'🦴', stack:64 },
gunpowder: { n:'Порох', icon:'💥', stack:64 }
};
// Seed мира для детерминированной генерации
@ -1836,11 +1844,130 @@ function customConfirm(msg, onYes) {
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';
@ -1870,7 +1997,9 @@ function customConfirm(msg, onYes) {
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,
@ -1968,6 +2097,8 @@ function customConfirm(msg, onYes) {
player.y = saveData.player.y;
player.hunger = saveData.player.hunger;
player.o2 = saveData.player.o2;
player.xp = saveData.player.xp || 0;
player.level = saveData.player.level || 1;
// Обновляем spawnPoint на позицию из сохранения
spawnPoint.x = player.x;
@ -2459,11 +2590,8 @@ function customConfirm(msg, onYes) {
}
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);
@ -3267,11 +3395,8 @@ function customConfirm(msg, onYes) {
}
}
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;
}
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);
@ -3757,6 +3882,13 @@ function customConfirm(msg, onYes) {
ctx.fillText('Нажмите на кровать чтобы проснуться', W/2, H/2 + 40);
}
// Рисуем дропы
drawDrops(ctx);
// Пикап дропов
pickupDrops();
// Popup уровня
drawLevelUpPopup(ctx);
// Миникарта (обновляем раз в ~4 кадра для оптимизации)
if(minimapOpen && Math.random() < 0.25){
renderMinimap();

3769
game.js.bak Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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>
@ -92,6 +93,6 @@
</div>
</div>
<script src="game.js?v=18"></script>
<script src="game.js?v=19"></script>
</body>
</html>