/* eslint-disable no-restricted-globals */ /** * ZombieAIWorker — Web Worker для AI зомби. * * Главный поток (ZombieManager) — рендер: отрисовка моделей, position.set, * rotation.set, _applyZombiePose. Никакого FSM, raycast, surface scan здесь. * * Worker — чистая JS-логика без Babylon: FSM (idle/wander/chase/attack), * выбор wanderTarget, обнаружение игрока по дистанции, surface height, * boundary clamp, гравитация, генерация событий (attack, sound). * * Протокол: * main → worker: * { type: 'init', surfaceMap, worldHalf } * — запустить worker, дать слепок поверхности. * { type: 'updateSurface', surfaceMap } * — карта блоков изменилась (редко, например при загрузке). * { type: 'addZombie', id, x, y, z, opts } * — добавить зомби со стартовой позицией. * { type: 'removeZombie', id } * — удалить. * { type: 'tick', playerX, playerY, playerZ, now, dt } * — тикни AI, верни новые состояния. Главный поток зовёт это раз * в ~50мс (не каждый кадр). * * worker → main: * { type: 'state', zombies: [{id, x, y, z, yaw, state, isMoving, walkPhase}], * events: [{type:'attack', id}, ...] } * — текущие позиции и события за период с прошлого тика. */ const HIDE_DIST = 80; const ATTACK_RANGE_DEFAULT = 1.6; // Состояние всех зомби в worker'е const zombies = new Map(); let surfaceMap = null; // Map let worldHalf = 40; // Шаг разреженной части карты (гладкий ландшафт семплится с шагом 2м, // блоки — с шагом 1). При промахе по точной ячейке ищем ближайшие // заполненные клетки с этим шагом и интерполируем. let surfaceStep = 1; // Поверхность под (x, z) — берём из surfaceMap. // Блоки лежат в каждой целой ячейке; гладкий ландшафт — разреженно // (шаг surfaceStep). Если точной ячейки нет — ищем 4 ближайшие узла // сетки шага surfaceStep и билинейно интерполируем. Это убирает // провал зомби сквозь гладкий ландшафт (раньше fallback был 0). function surfaceHeightAt(x, z) { if (!surfaceMap) return 0; const gx = Math.round(x); const gz = Math.round(z); // 1. Точная ячейка (блоки кладутся в каждую целую клетку) const exact = surfaceMap.get(gx * 65537 + gz); if (exact !== undefined) return exact; // 2. Разреженная сетка гладкого ландшафта — билинейная интерполяция const st = surfaceStep > 0 ? surfaceStep : 2; const c0 = Math.floor(x / st) * st; const r0 = Math.floor(z / st) * st; const h00 = surfaceMap.get(c0 * 65537 + r0); const h10 = surfaceMap.get((c0 + st) * 65537 + r0); const h01 = surfaceMap.get(c0 * 65537 + (r0 + st)); const h11 = surfaceMap.get((c0 + st) * 65537 + (r0 + st)); const vals = []; if (h00 !== undefined) vals.push(h00); if (h10 !== undefined) vals.push(h10); if (h01 !== undefined) vals.push(h01); if (h11 !== undefined) vals.push(h11); if (vals.length === 0) return 0; const avg = vals.reduce((a, b) => a + b, 0) / vals.length; const v00 = h00 !== undefined ? h00 : avg; const v10 = h10 !== undefined ? h10 : avg; const v01 = h01 !== undefined ? h01 : avg; const v11 = h11 !== undefined ? h11 : avg; const tx = (x - c0) / st; const tz = (z - r0) / st; const a = v00 * (1 - tx) + v10 * tx; const b = v01 * (1 - tx) + v11 * tx; return a * (1 - tz) + b * tz; } function pickWanderTarget(cx, cz, radius) { const a = Math.random() * Math.PI * 2; const r = Math.random() * radius; let tx = cx + Math.cos(a) * r; let tz = cz + Math.sin(a) * r; const half = worldHalf - 1; if (tx > half) tx = half; if (tx < -half) tx = -half; if (tz > half) tz = half; if (tz < -half) tz = -half; return { x: tx, z: tz }; } function lerpAngle(a, b, t) { let diff = b - a; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; return a + diff * Math.min(1, t); } /** Тик одного зомби. Возвращает массив событий (например, attack). */ function tickZombie(z, dt, px, py, pz, now) { const dx = px - z.x; const dz = pz - z.z; const dy = (py ?? 0) - (z.y ?? 0); const dist2d = Math.sqrt(dx * dx + dz * dz); // 3D-расстояние: важно для FSM. Если зомби на y=4, а игрок на y=10 // (стоит на крыше блочного дома), XZ-расстояние может быть 0.5м, но // фактически он не дотягивается. Используем 3D для attack/chase. const dist3d = Math.sqrt(dx * dx + dy * dy + dz * dz); z.distToPlayer = dist3d; // Скрытие далёких зомби — в main thread (не тут). if (dist2d >= HIDE_DIST) { z.visible = false; return null; } z.visible = true; const opts = z.opts; const events = []; // FSM. // attack: либо 3D-расстояние в зоне поражения, ЛИБО игрок ПРЯМО НАД // зомби (запрыгнул на голову) — dist2d мал, dy умеренный. Без этого // игрок «топчет» зомби стоя на нём, выходит из attackRange по dist3d // и зомби бесполезно крутится на месте. const onHead = dist2d < opts.attackRange && Math.abs(dy) < 3.5; if (dist3d < opts.attackRange || onHead) { z.state = 'attack'; } else if (dist3d < opts.detectionRadius) { z.state = 'chase'; } else if (z.state !== 'wander') { z.state = 'wander'; z.wanderTarget = pickWanderTarget(z.x, z.z, opts.wanderRadius); z.stateTime = 0; z.idlePauseUntil = null; } let moveDir = null; let speed = 0; if (z.state === 'attack') { // attack уже сработал по 3D-расстоянию в FSM (dist3d < attackRange), // дополнительная проверка не нужна. if (now - z.lastAttackTime > opts.attackCooldown) { z.lastAttackTime = now; events.push({ type: 'attack', id: z.id, damage: opts.attackDamage }); } const targetYaw = Math.atan2(dx, dz) + Math.PI; z.yaw = lerpAngle(z.yaw, targetYaw, dt * 5); } else if (z.state === 'chase') { // Двигаемся в XZ к игроку (по горизонтали), 2D-нормализация направления. const len = Math.max(0.001, dist2d); moveDir = { x: dx / len, z: dz / len }; speed = opts.speed; } else { // wander z.stateTime += dt; if (z.idlePauseUntil != null) { if (z.stateTime >= z.idlePauseUntil) { z.idlePauseUntil = null; z.stateTime = 0; z.wanderTarget = pickWanderTarget(z.x, z.z, opts.wanderRadius); } } else { const tx = z.wanderTarget.x - z.x; const tz = z.wanderTarget.z - z.z; const tdist = Math.sqrt(tx * tx + tz * tz); const reached = tdist < 0.7; const tooLong = z.stateTime > 15; if (reached || tooLong) { z.idlePauseUntil = 1.5 + Math.random() * 1.5; z.stateTime = 0; } else { moveDir = { x: tx / tdist, z: tz / tdist }; speed = opts.wanderSpeed; } } } // Гравитация if (z.vy == null) z.vy = 0; z.vy += -18 * dt; if (z.vy < -25) z.vy = -25; let newY = z.y + z.vy * dt; const surfaceY = surfaceHeightAt(z.x, z.z); if (newY <= surfaceY) { newY = surfaceY; z.vy = 0; z.onGround = true; } else { z.onGround = false; } z.y = newY; // Clamp в границы мира const halfClamp = worldHalf - 0.5; if (z.x > halfClamp) z.x = halfClamp; if (z.x < -halfClamp) z.x = -halfClamp; if (z.z > halfClamp) z.z = halfClamp; if (z.z < -halfClamp) z.z = -halfClamp; let stepped = false; if (moveDir) { const stepX = moveDir.x * speed * dt; const stepZ = moveDir.z * speed * dt; const half = worldHalf - 1.0; const tryX = z.x + stepX; const tryZ = z.z + stepZ; const outX = tryX > half || tryX < -half; const outZ = tryZ > half || tryZ < -half; const targetSurface = surfaceHeightAt(tryX, tryZ); const heightDiff = targetSurface - z.y; if (heightDiff <= 1.05 && !outX && !outZ) { z.x = tryX; z.z = tryZ; stepped = true; if (heightDiff > 0.05 && z.onGround) z.vy = 6; } else { const onlyXok = !outX && (surfaceHeightAt(z.x + stepX, z.z) - z.y) <= 1.05; const onlyZok = !outZ && (surfaceHeightAt(z.x, z.z + stepZ) - z.y) <= 1.05; if (onlyXok) { z.x += stepX; stepped = true; } else if (onlyZok) { z.z += stepZ; stepped = true; } } if (!stepped && z.state === 'wander') { z.idlePauseUntil = 0.8 + Math.random() * 1.2; z.stateTime = 0; z.wanderTarget = pickWanderTarget(z.x, z.z, opts.wanderRadius); moveDir = null; } } z.isMoving = !!moveDir; if (moveDir) { const targetYaw = Math.atan2(moveDir.x, moveDir.z) + Math.PI; z.yaw = lerpAngle(z.yaw, targetYaw, dt * 5); } if (z.walkPhase == null) z.walkPhase = 0; if (z.isMoving) { const stepFreq = (z.state === 'chase') ? 8 : 5; z.walkPhase += dt * stepFreq; } return events.length ? events : null; } // === Сообщения main → worker === self.onmessage = (e) => { const msg = e.data; switch (msg.type) { case 'init': case 'updateSurface': { // surfaceMap приходит как plain object {key: y}, конвертим в Map surfaceMap = new Map(); for (const k in msg.surfaceMap) { surfaceMap.set(parseInt(k, 10), msg.surfaceMap[k]); } if (typeof msg.worldHalf === 'number') worldHalf = msg.worldHalf; if (typeof msg.surfaceStep === 'number' && msg.surfaceStep > 0) { surfaceStep = msg.surfaceStep; } break; } case 'addZombie': { const opts = msg.opts || {}; zombies.set(msg.id, { id: msg.id, x: msg.x, y: msg.y, z: msg.z, yaw: 0, vy: 0, state: 'wander', stateTime: 0, idlePauseUntil: null, wanderTarget: pickWanderTarget(msg.x, msg.z, opts.wanderRadius || 8), lastAttackTime: 0, onGround: false, visible: true, isMoving: false, walkPhase: 0, opts: { speed: opts.speed ?? 2.2, wanderSpeed: opts.wanderSpeed ?? 1.2, detectionRadius: opts.detectionRadius ?? 12, attackRange: opts.attackRange ?? ATTACK_RANGE_DEFAULT, attackCooldown: opts.attackCooldown ?? 1.5, attackDamage: opts.attackDamage ?? 10, wanderRadius: opts.wanderRadius ?? 8, }, }); break; } case 'removeZombie': { zombies.delete(msg.id); break; } case 'tick': { const { playerX, playerY, playerZ, now, dt } = msg; const out = []; const allEvents = []; for (const z of zombies.values()) { const events = tickZombie(z, dt, playerX, playerY, playerZ, now); if (events) { for (const ev of events) { allEvents.push({ ...ev, id: z.id }); } } out.push({ id: z.id, x: z.x, y: z.y, z: z.z, yaw: z.yaw, state: z.state, isMoving: z.isMoving, walkPhase: z.walkPhase, visible: z.visible, }); } self.postMessage({ type: 'state', zombies: out, events: allEvents }); break; } default: break; } };