studio/src/editor/engine/ZombieAIWorker.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

332 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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<numericKey, topY>
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;
}
};