grechka-game/srcv3/entities/mob-ai3d.js

278 lines
10 KiB
JavaScript

// ==================== МОБЫ 3D: ИИ + ФИЗИКА — GrechkaCraft v3 ====================
// Вся логика из 2D-игры (все 9 видов, ночные/дневные, лут), но цель —
// настоящая 3D-точка игрока (x,y,z) и прыжки через стены по всем осям.
import { state } from '../core/state.js';
import { GRAV, GRAV_WATER, TILE } from '../core/constants.js';
import { updateWaterFlag3 } from '../physics/water-detect3d.js';
import { calculateDamage } from '../entities/player.js';
import { playSound } from '../audio/sound-engine.js';
import { explodeTNT3D } from '../world/tnt3d.js';
import { resolveY, resolveX, resolveZ } from '../physics/collision3d.js';
import { getBlock } from '../world/world-storage3d.js';
import { rebuildHotbar } from '../ui/hotbar.js';
export function isNight() {
return state.worldTime > 0.5;
}
// Центры сущностей в тайлах
function cx(e) { return (e.x + e.w / 2) / TILE; }
function cy(e) { return (e.y + e.h / 2) / TILE; }
function cz(e) { return ((e.z || 0) + (e.d || e.w) / 2) / TILE; }
function playerC() {
const p = state.player;
return { x: cx(p), y: cy(p), z: cz(p) };
}
function dist3(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
}
// Тянет моба к игроку по обеим горизонтальным осям (X и Z)
function steerToPlayer(m, pc) {
const dx = pc.x - cx(m);
const dz = pc.z - cz(m);
const len = Math.hypot(dx, dz) || 1;
m.vx = (dx / len) * (m.speed || 60);
m.vz = (dz / len) * (m.speed || 60);
m.dir = dx >= 0 ? 1 : -1;
m.faceYaw = Math.atan2(dz, dx);
}
function touchPlayer(m, pc, damage, knock = 1) {
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
if (d < 1.6 && state.player.invuln <= 0) {
state.player.hp -= calculateDamage(damage);
state.player.invuln = 0.8;
const dx = state.player.x - m.x;
const dz = (state.player.z || 0) - (m.z || 0);
const len = Math.hypot(dx, dz) || 1;
state.player.vx += (dx / len) * 420 * knock;
state.player.vz += (dz / len) * 420 * knock;
state.player.vy -= 260 * knock;
playSound('hit1');
return true;
}
return false;
}
export function mobAI(m, dt) {
updateWaterFlag3(m, dt);
const pc = playerC();
const night = isNight();
// ---------- ночные мобы днём горят ----------
// ВАЖНО: не делаем ранний return — иначе моб зависает в воздухе
// (физика ниже не выполняется). Ставим флаг и идём дальше.
const burning = (m.kind === 'zombie' || m.kind === 'creeper' || m.kind === 'skeleton') && !night;
if (burning) {
m.burning = true;
m.hp -= 30 * dt;
m.vx *= 0.5;
m.vz *= 0.5;
} else {
m.burning = false;
}
if (burning) {
// горящий моб только падает и горит — никакого ИИ
} else if (m.kind === 'zombie') {
steerToPlayer(m, pc);
if (m.inWater && Math.random() < 0.06) m.vy = -260;
touchPlayer(m, pc, 15);
} else if (m.kind === 'creeper') {
steerToPlayer(m, pc);
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
if (m.inWater && Math.random() < 0.06) m.vy = -260;
if (d < 3) {
m.fuse -= dt;
if (m.fuse <= 0) {
explodeTNT3D({ gx: Math.floor(cx(m)), gy: Math.floor(cy(m)), gz: Math.floor(cz(m)) }, state);
m.hp = 0;
}
} else if (d < 2.5) {
m.fuse = 0.5;
}
} else if (m.kind === 'skeleton') {
steerToPlayer(m, pc);
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
m.shootCooldown -= dt;
if (d < 15 && m.shootCooldown <= 0) {
m.shootCooldown = 2.0;
const ox = m.x + m.w / 2, oy = m.y + m.h / 3, oz = (m.z || 0) + (m.d || m.w) / 2;
const tx = state.player.x + state.player.w / 2;
const ty = state.player.y + state.player.h / 2;
const tz = (state.player.z || 0) + (state.player.d || state.player.w) / 2;
// полёт стрелы по 3D
const ddx = tx - ox, ddy = ty - oy, ddz = tz - oz;
const dist = Math.hypot(ddx, ddy, ddz) || 1;
const speed = 450;
state.projectiles.push({
x: ox, y: oy, z: oz,
vx: ddx / dist * speed,
vy: ddy / dist * speed,
vz: ddz / dist * speed,
dmg: 6, owner: 'mob', life: 3,
});
}
} else if (m.kind === 'scorpion') {
steerToPlayer(m, pc);
if (m.inWater && Math.random() < 0.06) m.vy = -260;
if (touchPlayer(m, pc, 8)) state.player.slowTimer = 3;
} else if (m.kind === 'polar_bear') {
// нейтрален: бродит
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 2.0 + Math.random() * 3;
const a = Math.random() * Math.PI * 2;
m.walkX = Math.cos(a);
m.walkZ = Math.sin(a);
if (Math.random() < 0.3) { m.walkX = 0; m.walkZ = 0; }
}
m.vx = (m.walkX || 0) * m.speed;
m.vz = (m.walkZ || 0) * m.speed;
if (m.inWater) m.vy = -120;
touchPlayer(m, pc, 10, 1.2);
} else if (m.kind === 'slime') {
// прыгает к игроку
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 1.5 + Math.random() * 1.5;
steerToPlayer(m, pc);
m.vy = -200;
}
touchPlayer(m, pc, 8);
} else if (m.kind === 'eagle') {
// летает, пикирует
const d = dist3({ x: cx(m), y: cy(m), z: cz(m) }, pc);
m.flying = true; // флаг ДО физики, иначе орёл получает гравитацию
if (d < 20) {
const dx = pc.x - cx(m), dz = pc.z - cz(m), dy = pc.y - cy(m);
const hl = Math.hypot(dx, dz) || 1;
m.vx = dx / hl * m.speed;
m.vz = dz / hl * m.speed;
m.vy = dy > 0 ? 60 : -60;
} else {
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 2 + Math.random() * 3;
const a = Math.random() * Math.PI * 2;
m.walkX = Math.cos(a);
m.walkZ = Math.sin(a);
}
m.vx = (m.walkX || 0) * m.speed * 0.5;
m.vz = (m.walkZ || 0) * m.speed * 0.5;
m.vy = Math.sin(performance.now() / 1000) * 30;
}
if (d < 1.6 && state.player.invuln <= 0) {
state.player.hp -= calculateDamage(10);
state.player.invuln = 0.8;
state.player.vy -= 200;
playSound('hit1');
}
m.vy *= 0.5;
} else {
// животные (pig, chicken): бродят
m.aiT -= dt;
if (m.aiT <= 0) {
m.aiT = 1.8 + Math.random() * 2.5;
const a = Math.random() * Math.PI * 2;
m.walkX = Math.cos(a);
m.walkZ = Math.sin(a);
if (Math.random() < 0.25) { m.walkX = 0; m.walkZ = 0; }
}
const sp = m.kind === 'chicken' ? 55 : 40;
m.vx = (m.walkX || 0) * sp;
m.vz = (m.walkZ || 0) * sp;
if (m.inWater) m.vy = -120;
}
// ---------- прыжок через препятствие по всем осям ----------
if (m.grounded && !m.flying && m.kind !== 'eagle') {
const speed = Math.hypot(m.vx || 0, m.vz || 0);
if (speed > 5) {
// Проверяем отдельно по X и по Z, каждая на своей оси:
// препятствие по X смотрим в колонке Z моба, и наоборот.
const feetGY = Math.floor((m.y + m.h - 1) / TILE);
const bodyGY = Math.floor((m.y + m.h / 2) / TILE);
const mcz = Math.floor(cz(m));
let blocked = false;
if (m.vx > 0) blocked = blocked || !!getBlock(Math.floor((m.x + m.w + 2) / TILE), bodyGY, mcz);
if (m.vx < 0) blocked = blocked || !!getBlock(Math.floor((m.x - 2) / TILE), bodyGY, mcz);
if (m.vz > 0) blocked = blocked || !!getBlock(Math.floor(cx(m)), bodyGY, Math.floor(((m.z || 0) + (m.d || m.w) + 2) / TILE));
if (m.vz < 0) blocked = blocked || !!getBlock(Math.floor(cx(m)), bodyGY, Math.floor(((m.z || 0) - 2) / TILE));
if (blocked) m.vy = -280;
void feetGY;
}
}
// ---------- ФИЗИКА ----------
if (!m.flying) {
const g = m.inWater ? GRAV_WATER : GRAV;
m.vy += g * dt;
}
m.y += m.vy * dt;
m.x += (m.vx || 0) * dt;
m.z += (m.vz || 0) * dt;
resolveX(m, dt);
resolveZ(m, dt);
resolveY(m, dt);
// орёл не падает
if (m.flying) m.grounded = false;
}
// Лут — та же таблица, что в 2D
export 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}],
scorpion: [{item:'scorpion_stinger',min:0,max:1,chance:0.4}],
polar_bear:[{item:'polar_fur',min:1,max:2,chance:0.8},{item:'meat',min:1,max:2,chance:1}],
slime: [{item:'slime_ball',min:1,max:2,chance:1}],
eagle: [{item:'eagle_feather',min:1,max:2,chance:0.7}],
};
return table[kind] || [];
}
// Выдаёт лут моба в инвентарь. Возвращает список выданных предметов.
export function grantMobLoot(kind) {
const granted = [];
for (const drop of getMobLoot(kind)) {
if (Math.random() > drop.chance) continue;
const qty = drop.min + Math.floor(Math.random() * (drop.max - drop.min + 1));
if (qty <= 0) continue;
if (state.inv[drop.item] === undefined) state.inv[drop.item] = 0;
state.inv[drop.item] += qty;
granted.push({ item: drop.item, qty });
}
if (granted.length) rebuildHotbar();
return granted;
}
// Убийство моба: помечает мёртвым, даёт лут, чистит из массива.
// Вызывается из mouse-handler (удар) и из цикла (смерть от урона/взрыва).
export function killMob(m) {
if (m.dead) return [];
m.dead = true;
m.hp = 0;
const granted = grantMobLoot(m.kind);
if (m.kind === 'chicken') playSound('hurt_chicken');
else playSound('attack');
const i = state.mobs.indexOf(m);
if (i >= 0) state.mobs.splice(i, 1);
return granted;
}
// Смерть от урона (взрыв, падение в лаву и т.п.) — вызывается из цикла каждый кадр.
export function reapDeadMobs() {
for (let i = state.mobs.length - 1; i >= 0; i--) {
const m = state.mobs[i];
if (m.hp <= 0) killMob(m);
}
}