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)
332 lines
13 KiB
JavaScript
332 lines
13 KiB
JavaScript
/* 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;
|
||
}
|
||
};
|