Compare commits
No commits in common. "2645337bdda4f3f46c2b031e0062babe79a6f284" and "e477d652f67c88a829cb0c8a16bbc1997cfc9f11" have entirely different histories.
2645337bdd
...
e477d652f6
@ -168,4 +168,3 @@ git push origin feature/моя-фича
|
||||
- Issues и PR: https://git.rublox.pro/rublox/studio
|
||||
- Безопасность: [SECURITY.md](./SECURITY.md)
|
||||
|
||||
<!-- e2e-test 2026-06-07: проверка workflow разработчиков после восстановления -->
|
||||
|
||||
@ -363,9 +363,4 @@ export const GAMES = [
|
||||
desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.',
|
||||
mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'],
|
||||
previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true },
|
||||
{ id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles',
|
||||
title: 'Зомби-арена — бластер и цифры урона',
|
||||
desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).',
|
||||
mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'],
|
||||
previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true },
|
||||
];
|
||||
|
||||
@ -8782,91 +8782,6 @@ game.achievements.unlock('first_coin');`}</Code>
|
||||
),
|
||||
},
|
||||
|
||||
'guide-floaters': {
|
||||
body: (
|
||||
<>
|
||||
<h3 className="lessonH">Что получится</h3>
|
||||
<p>
|
||||
Мини-шутер: волны <b>зомби бегут к игроку</b>, ты отстреливаешь
|
||||
их из <b>бластера</b>, а над каждой целью всплывает <b>облачко
|
||||
урона</b> — как в Roblox-RPG (Pet Sim, Anime Adventures). Зомби
|
||||
гибнут, счётчик растёт, волны усиливаются.
|
||||
</p>
|
||||
|
||||
<Shot src="guide-floaters-scene.png" wide
|
||||
caption="Зомби сбегаются к игроку, бластер стреляет — над целью всплывают красные облачка урона «-25»." />
|
||||
|
||||
<h3 className="lessonH">Шаг 1. Бластер + авто-облачка над мобами</h3>
|
||||
<p>
|
||||
Две строки превращают игру в шутер с фидбеком урона: выдаём
|
||||
бластер и включаем <b>авто-floater</b> — теперь <i>любой</i> урон
|
||||
по NPC сам рисует «-N» над целью, вручную вызывать ничего не надо.
|
||||
</p>
|
||||
<ScriptKind kind="global" />
|
||||
<Code>{`game.player.giveTool('blaster-blaster-a', { equip: true }); // бластер в руки
|
||||
game.fx.autoMobFloaters(true); // облачко урона над любым мобом при попадании`}</Code>
|
||||
|
||||
<h3 className="lessonH">Шаг 2. Волны зомби, идущих к игроку</h3>
|
||||
<Code>{`function spawnWave(n){
|
||||
const pl = game.player.position;
|
||||
for (let i = 0; i < n; i++){
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
const e = game.scene.spawnNpc('skin_retro-zombie', {
|
||||
x: pl.x + Math.cos(a)*18, z: pl.z + Math.sin(a)*18,
|
||||
name: 'Зомби', hp: 100, speed: 2.6,
|
||||
});
|
||||
if (e && e.follow) e.follow('player'); // зомби преследует игрока
|
||||
}
|
||||
}
|
||||
game.after(1.5, () => spawnWave(5));
|
||||
game.every(14, () => spawnWave(8));`}</Code>
|
||||
<p>
|
||||
Стрелять из бластера — ЛКМ. В режиме от 3-го лица пуля летит
|
||||
<b> туда, куда кликнул</b> курсором. Попал по зомби → облачко
|
||||
урона (благодаря <code>autoMobFloaters</code>), убил → засчитан.
|
||||
</p>
|
||||
|
||||
<h3 className="lessonH">Ручной floater — все типы</h3>
|
||||
<p>Когда нужен полный контроль — рисуй цифру сам:</p>
|
||||
<Code>{`game.fx.damageFloater(pos, 25); // красный — обычный урон
|
||||
game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок
|
||||
game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный — лечение (+30)
|
||||
game.fx.damageFloater(pos, 50, { isMana: true }); // синий — мана
|
||||
game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`}</Code>
|
||||
<p>
|
||||
<b>position</b> — <code>{'{x,y,z}'}</code>, ссылка на объект или
|
||||
<code>'player'</code>; <b>value</b> — число или строка.
|
||||
</p>
|
||||
|
||||
<h3 className="lessonH">Стек и комикс-стиль</h3>
|
||||
<Code>{`// общий stackKey → удары сливаются в «-25 ×N» вместо кучи цифр
|
||||
game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id });
|
||||
// comicStyle → BAM! (>50), KAPOW! (>100), POW! (крит) на жёлтой звезде
|
||||
game.fx.damageFloater(pos, 120, { comicStyle: true });`}</Code>
|
||||
|
||||
<Note>
|
||||
Под капотом — пул из 30 переиспользуемых билборд-планов
|
||||
(object pool), поэтому даже при толпе зомби и спаме цифр FPS не
|
||||
проседает. Цифры всегда поверх геометрии и повёрнуты к камере.
|
||||
</Note>
|
||||
|
||||
<h3 className="lessonH">Почему это важно</h3>
|
||||
<p>
|
||||
Без облачек урона стрельба ощущается «впустую». Это базовый
|
||||
боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли.
|
||||
Связка <code>бластер + autoMobFloaters + волны NPC</code> — готовый
|
||||
каркас любого шутера/выживания.
|
||||
</p>
|
||||
|
||||
<Try>
|
||||
Сделай «огненный» урон: <code>damageFloater(pos, 15, {'{'} color:
|
||||
'#ff7a2a' {'}'})</code> каждые 0.5 сек 3 раза — эффект горения.
|
||||
Или увеличь HP зомби и добавь крит каждый 5-й выстрел.
|
||||
</Try>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/** Есть ли готовый текст урока для игры с таким id. */
|
||||
|
||||
@ -76,7 +76,6 @@ import { Environment } from './Environment';
|
||||
import { SkyboxManager } from './SkyboxManager';
|
||||
import { LeaderstatsManager } from './LeaderstatsManager';
|
||||
import { AchievementsManager } from './AchievementsManager';
|
||||
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
|
||||
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
|
||||
import { GameAudioManager } from './GameAudioManager';
|
||||
import { AssetManager } from './AssetManager';
|
||||
@ -1300,7 +1299,6 @@ export class BabylonScene {
|
||||
this.dynamics = new DynamicsManager(this);
|
||||
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
|
||||
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света)
|
||||
this.floaters = new FloaterManager(this); // задача 40 — damage floaters
|
||||
this.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
|
||||
this.achievements = new AchievementsManager(this); // задача 20 — достижения
|
||||
this.audioManager = new AudioManager();
|
||||
@ -1464,10 +1462,6 @@ export class BabylonScene {
|
||||
if (this._isPlaying && this.leaderstats) {
|
||||
this.leaderstats.tick();
|
||||
}
|
||||
// Damage floaters (задача 40) — анимация всплывающих цифр.
|
||||
if (this.floaters) {
|
||||
this.floaters.tick(dt);
|
||||
}
|
||||
// Анимация жидкостей — работает всегда (и в редакторе)
|
||||
if (this.blockManager) {
|
||||
this.blockManager.tick(dt);
|
||||
@ -2571,9 +2565,7 @@ export class BabylonScene {
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
// Клавиши с Ctrl/Cmd — это шорткаты (Ctrl+D/C/V/Z...), а не движение
|
||||
// камеры. Не кладём их в _codes, иначе камера «уезжает» (баг Ctrl+D).
|
||||
if (!e.ctrlKey && !e.metaKey) this._codes.add(e.code);
|
||||
this._codes.add(e.code);
|
||||
if (e.shiftKey) this._shiftDown = true;
|
||||
// Маршрутизация game.onKey в Play-режиме
|
||||
if (this._isPlaying && this.gameRuntime) {
|
||||
@ -5388,8 +5380,7 @@ export class BabylonScene {
|
||||
const sx = sel.x, sy = sel.y, sz = sel.z;
|
||||
const rotY = sel.rotationY || 0;
|
||||
const srcId = sel.instanceId;
|
||||
// Дубль появляется РОВНО на месте оригинала (как в Roblox Studio).
|
||||
this.modelManager.addInstance(typeId, sx, sy, sz, rotY)
|
||||
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
|
||||
.then(newId => {
|
||||
if (newId != null) {
|
||||
this._copyScriptsToNewObject('model', srcId, newId);
|
||||
@ -5405,7 +5396,7 @@ export class BabylonScene {
|
||||
const sx = sel.x, sy = sel.y, sz = sel.z;
|
||||
const rotY = sel.rotationY || 0;
|
||||
const srcUmId = sel.instanceId;
|
||||
this.userModelManager.addInstance(typeId, sx, sy, sz, rotY, {
|
||||
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
|
||||
currentUserId: this._currentUserId || null,
|
||||
}).then(newId => {
|
||||
if (newId != null) {
|
||||
@ -5417,7 +5408,7 @@ export class BabylonScene {
|
||||
});
|
||||
} else if (sel.type === 'primitive') {
|
||||
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
|
||||
x: sel.x, y: sel.y, z: sel.z,
|
||||
x: sel.x + 1, y: sel.y, z: sel.z,
|
||||
sx: sel.sx, sy: sel.sy, sz: sel.sz,
|
||||
// Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04).
|
||||
rotationX: sel.rotationX || 0,
|
||||
@ -6229,10 +6220,6 @@ export class BabylonScene {
|
||||
if (hit?.mesh && this.zombieManager) {
|
||||
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
|
||||
}
|
||||
// Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
|
||||
if (hit?.mesh && this.npcManager) {
|
||||
try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
|
||||
}
|
||||
if (this._onWeaponHit) {
|
||||
try { this._onWeaponHit(hit); } catch (e) {}
|
||||
}
|
||||
@ -8205,7 +8192,6 @@ export class BabylonScene {
|
||||
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
|
||||
try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
|
||||
try { this.achievements?.resetRuntime?.(); } catch (e) {}
|
||||
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
|
||||
// Сбрасываем таймер прохождения
|
||||
this._timerRunning = false;
|
||||
this._timerStartedAt = null;
|
||||
|
||||
@ -1,237 +0,0 @@
|
||||
/**
|
||||
* FloaterManager — всплывающие цифры урона (Damage Floaters), задача 40.
|
||||
*
|
||||
* game.fx.damageFloater(position, value, opts) → над точкой всплывает число,
|
||||
* поднимается вверх, покачивается, плавно гаснет. Цвета: damage/crit/heal/
|
||||
* mana/miss. Object pool из переиспользуемых billboard-планов (без create/
|
||||
* destroy на каждый удар). Стек одинаковых по stackKey («×N»). Комикс-стиль
|
||||
* (BAM!/KAPOW!/POW!).
|
||||
*
|
||||
* Билборд = плоскость с DynamicTexture (как LabelManager), billboardMode=7,
|
||||
* renderingGroupId=1 (всегда поверх геометрии), disableDepthWrite.
|
||||
*
|
||||
* Фича-парность: тот же модуль в rublox-player/src/engine/.
|
||||
*/
|
||||
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
|
||||
import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
|
||||
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
|
||||
import { Color3 } from '@babylonjs/core/Maths/math.color';
|
||||
import { Mesh } from '@babylonjs/core/Meshes/mesh';
|
||||
|
||||
const POOL_SIZE = 30;
|
||||
const TEX_W = 512, TEX_H = 256;
|
||||
|
||||
// Пресеты типов урона: цвет текста + множители.
|
||||
const PRESETS = {
|
||||
damage: { color: '#ff5a4a', stroke: '#3a0000' },
|
||||
crit: { color: '#ffd23a', stroke: '#5a3a00' },
|
||||
heal: { color: '#46e06a', stroke: '#063a14' },
|
||||
mana: { color: '#4aa8ff', stroke: '#001a3a' },
|
||||
miss: { color: '#b8b8b8', stroke: '#222222' },
|
||||
};
|
||||
|
||||
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
|
||||
|
||||
export class FloaterManager {
|
||||
constructor(scene3d) {
|
||||
this.s = scene3d;
|
||||
this.scene = scene3d.scene;
|
||||
this.pool = [];
|
||||
this._initialized = false;
|
||||
this._stacks = new Map(); // stackKey → slot (для накопления ×N)
|
||||
}
|
||||
|
||||
_init() {
|
||||
if (this._initialized) return;
|
||||
this._initialized = true;
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const tex = new DynamicTexture(`floaterTex_${i}`, { width: TEX_W, height: TEX_H }, this.scene, true);
|
||||
tex.hasAlpha = true;
|
||||
const plane = MeshBuilder.CreatePlane(`floater_${i}`, { width: 2.4, height: 1.2, sideOrientation: Mesh.DOUBLESIDE }, this.scene);
|
||||
const mat = new StandardMaterial(`floaterMat_${i}`, this.scene);
|
||||
mat.diffuseTexture = tex;
|
||||
mat.diffuseTexture.hasAlpha = true;
|
||||
mat.emissiveColor = new Color3(1, 1, 1);
|
||||
mat.diffuseColor = new Color3(0, 0, 0);
|
||||
mat.disableLighting = true;
|
||||
mat.backFaceCulling = false;
|
||||
mat.disableDepthWrite = true;
|
||||
mat.useAlphaFromDiffuseTexture = true;
|
||||
plane.material = mat;
|
||||
plane.billboardMode = 7;
|
||||
plane.renderingGroupId = 1;
|
||||
plane.isPickable = false;
|
||||
plane.setEnabled(false);
|
||||
this.pool.push({ plane, tex, mat, active: false, age: 0, lifetime: 0.8 });
|
||||
}
|
||||
}
|
||||
|
||||
_acquire() {
|
||||
for (const slot of this.pool) if (!slot.active) return slot;
|
||||
return null; // все заняты — пропускаем новый floater (норма)
|
||||
}
|
||||
|
||||
/**
|
||||
* Главный API. position: {x,y,z}; value: число|строка; opts — см. задачу 40.
|
||||
*/
|
||||
spawn(position, value, opts = {}) {
|
||||
this._init();
|
||||
if (!position) return;
|
||||
opts = opts || {};
|
||||
|
||||
// Стек: одинаковый stackKey за время жизни накапливает счётчик.
|
||||
if (opts.stackKey && this._stacks.has(opts.stackKey)) {
|
||||
const slot = this._stacks.get(opts.stackKey);
|
||||
if (slot.active) {
|
||||
slot.stackCount = (slot.stackCount || 1) + 1;
|
||||
slot.age = Math.min(slot.age, slot.lifetime * 0.3); // продлеваем
|
||||
this._draw(slot, slot.baseText, slot.preset, slot.fontSize, slot.comic, slot.stackCount);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const slot = this._acquire();
|
||||
if (!slot) return;
|
||||
|
||||
// Тип floater'а.
|
||||
let kind = 'damage';
|
||||
if (opts.isCrit) kind = 'crit';
|
||||
else if (opts.isHeal) kind = 'heal';
|
||||
else if (opts.isMana) kind = 'mana';
|
||||
else if (opts.isMiss) kind = 'miss';
|
||||
const preset = PRESETS[kind];
|
||||
const color = opts.color || preset.color;
|
||||
const stroke = opts.strokeColor || preset.stroke;
|
||||
|
||||
let fontSize = Number.isFinite(opts.fontSize) ? opts.fontSize : 60;
|
||||
let floatHeight = Number.isFinite(opts.floatHeight) ? opts.floatHeight : 2;
|
||||
let lifetime = Number.isFinite(opts.lifetime) ? opts.lifetime : 0.9;
|
||||
const randomOffset = Number.isFinite(opts.randomOffset) ? opts.randomOffset : (opts.isCrit ? 0.5 : 0.25);
|
||||
|
||||
// Текст: число с минусом (урон) или как есть (строка / heal с плюсом).
|
||||
let baseText;
|
||||
if (typeof value === 'string') baseText = value;
|
||||
else if (opts.isHeal) baseText = '+' + value;
|
||||
else if (opts.isMiss) baseText = String(value);
|
||||
else baseText = '-' + Math.abs(value);
|
||||
|
||||
if (opts.isCrit) { fontSize = Math.round(fontSize * 1.4); floatHeight *= 1.2; }
|
||||
|
||||
slot.active = true;
|
||||
slot.age = 0;
|
||||
slot.lifetime = lifetime;
|
||||
slot.floatHeight = floatHeight;
|
||||
slot.isCrit = !!opts.isCrit;
|
||||
slot.color = color; slot.stroke = stroke;
|
||||
slot.preset = { color, stroke };
|
||||
slot.fontSize = fontSize;
|
||||
slot.comic = !!opts.comicStyle;
|
||||
slot.baseText = baseText;
|
||||
slot.stackCount = 1;
|
||||
slot.stackKey = opts.stackKey || null;
|
||||
|
||||
const rx = (Math.random() - 0.5) * 2 * randomOffset;
|
||||
const rz = (Math.random() - 0.5) * 2 * randomOffset;
|
||||
slot.startX = position.x + rx;
|
||||
slot.startY = position.y + (Number.isFinite(opts.yOffset) ? opts.yOffset : 1.5);
|
||||
slot.startZ = position.z + rz;
|
||||
slot.plane.position.set(slot.startX, slot.startY, slot.startZ);
|
||||
slot.plane.scaling.set(1, 1, 1);
|
||||
slot.plane.setEnabled(true);
|
||||
|
||||
this._draw(slot, baseText, slot.preset, fontSize, slot.comic, 1);
|
||||
|
||||
if (opts.stackKey) this._stacks.set(opts.stackKey, slot);
|
||||
}
|
||||
|
||||
_draw(slot, baseText, preset, fontSize, comic, stackCount) {
|
||||
const ctx = slot.tex.getContext();
|
||||
ctx.clearRect(0, 0, TEX_W, TEX_H);
|
||||
|
||||
let text = baseText;
|
||||
if (comic) {
|
||||
const num = parseInt(String(baseText).replace(/[^0-9]/g, ''), 10) || 0;
|
||||
if (slot.isCrit) text = 'POW!';
|
||||
else if (num > 100) text = 'KAPOW!';
|
||||
else if (num > 50) text = 'BAM!';
|
||||
text = text;
|
||||
}
|
||||
if (stackCount > 1) text = baseText + ' ×' + stackCount;
|
||||
|
||||
const fs = comic ? Math.round(fontSize * 1.1) : fontSize;
|
||||
ctx.font = `900 ${fs}px ${comic ? 'Bangers, Impact, sans-serif' : 'Inter, Arial, sans-serif'}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Комикс-фон: жёлтая звезда-вспышка.
|
||||
if (comic) {
|
||||
ctx.save();
|
||||
ctx.translate(TEX_W / 2, TEX_H / 2);
|
||||
ctx.fillStyle = 'rgba(255,210,60,0.9)';
|
||||
ctx.beginPath();
|
||||
const spikes = 10, outer = 130, inner = 70;
|
||||
for (let i = 0; i < spikes * 2; i++) {
|
||||
const r = i % 2 === 0 ? outer : inner;
|
||||
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
|
||||
const px = Math.cos(a) * r, py = Math.sin(a) * r * 0.55;
|
||||
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Обводка + текст.
|
||||
ctx.strokeStyle = comic ? '#000' : preset.stroke;
|
||||
ctx.lineWidth = Math.max(6, fs * 0.16);
|
||||
ctx.strokeText(text, TEX_W / 2, TEX_H / 2);
|
||||
ctx.fillStyle = comic ? '#d22' : preset.color;
|
||||
ctx.fillText(text, TEX_W / 2, TEX_H / 2);
|
||||
|
||||
slot.tex.update(true);
|
||||
}
|
||||
|
||||
/** Вызывать каждый кадр (анимация подъёма + fade + покачивание + crit-pop). */
|
||||
tick(dt) {
|
||||
if (!this._initialized) return;
|
||||
for (const slot of this.pool) {
|
||||
if (!slot.active) continue;
|
||||
slot.age += dt;
|
||||
const t = slot.age / slot.lifetime;
|
||||
if (t >= 1) {
|
||||
slot.active = false;
|
||||
slot.plane.setEnabled(false);
|
||||
if (slot.stackKey && this._stacks.get(slot.stackKey) === slot) this._stacks.delete(slot.stackKey);
|
||||
continue;
|
||||
}
|
||||
const ease = easeOutQuad(t);
|
||||
slot.plane.position.y = slot.startY + slot.floatHeight * ease;
|
||||
slot.plane.position.x = slot.startX + Math.sin(slot.age * 5) * 0.12;
|
||||
|
||||
// fade-in 0.12 / hold / fade-out 0.25
|
||||
let alpha = 1;
|
||||
if (t < 0.12) alpha = t / 0.12;
|
||||
else if (t > 0.75) alpha = 1 - (t - 0.75) / 0.25;
|
||||
slot.mat.alpha = Math.max(0, Math.min(1, alpha));
|
||||
|
||||
// crit pop: scale 1 → 1.3 → 1 в первые 0.4 жизни
|
||||
if (slot.isCrit) {
|
||||
let s = 1;
|
||||
if (t < 0.2) s = 1 + (t / 0.2) * 0.3;
|
||||
else if (t < 0.4) s = 1.3 - ((t - 0.2) / 0.2) * 0.3;
|
||||
slot.plane.scaling.set(s, s, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const slot of this.pool) {
|
||||
try { slot.plane.dispose(); slot.tex.dispose(); slot.mat.dispose(); } catch (e) {}
|
||||
}
|
||||
this.pool = []; this._stacks.clear(); this._initialized = false;
|
||||
}
|
||||
resetRuntime() {
|
||||
for (const slot of this.pool) { slot.active = false; slot.plane?.setEnabled(false); }
|
||||
this._stacks.clear();
|
||||
}
|
||||
}
|
||||
@ -1891,35 +1891,6 @@ export class GameRuntime {
|
||||
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
|
||||
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); return; }
|
||||
|
||||
// === Damage Floaters (задача 40) — всплывающие цифры урона ===
|
||||
if (cmd === 'fx.damageFloater') {
|
||||
try {
|
||||
let pos = payload?.position;
|
||||
// ref-строка ('player'|'primitive:N'|'model:N') → координаты объекта.
|
||||
if (typeof pos === 'string') {
|
||||
if (pos === 'player') {
|
||||
const pl = this.scene3d?.player;
|
||||
const p = pl ? (pl._pos || pl.position || pl.mesh?.position) : null;
|
||||
pos = p ? { x: p.x, y: p.y, z: p.z } : null;
|
||||
} else {
|
||||
const tgt = this._resolveTweenTarget(pos);
|
||||
pos = tgt ? { x: tgt.data.x || 0, y: tgt.data.y || 0, z: tgt.data.z || 0 } : null;
|
||||
}
|
||||
}
|
||||
if (pos) this.scene3d?.floaters?.spawn(pos, payload?.value, payload?.opts || {});
|
||||
} catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (cmd === 'fx.autoMobFloaters') {
|
||||
try {
|
||||
if (this.scene3d?.npcManager) {
|
||||
this.scene3d.npcManager._autoFloater = payload?.enabled
|
||||
? { opts: payload?.opts || {} } : null;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
|
||||
if (cmd === 'fx.create') {
|
||||
// payload: { kind: 'beam'|'trail', localRef, ... }
|
||||
|
||||
@ -161,20 +161,6 @@ export class NpcManager {
|
||||
r15Animator,
|
||||
};
|
||||
this.npcs.set(id, npc);
|
||||
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
|
||||
// в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/
|
||||
// floater'ы не срабатывают (задача 40).
|
||||
try {
|
||||
const root = npc.data && npc.data.rootMesh;
|
||||
if (root) {
|
||||
root.isPickable = true;
|
||||
root.metadata = Object.assign({}, root.metadata, { npcId: id });
|
||||
for (const m of root.getChildMeshes(false)) {
|
||||
m.isPickable = true;
|
||||
m.metadata = Object.assign({}, m.metadata, { npcId: id });
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return id;
|
||||
}
|
||||
|
||||
@ -306,43 +292,10 @@ export class NpcManager {
|
||||
damage(id, amount) {
|
||||
const npc = this.npcs.get(Number(id));
|
||||
if (!npc || npc.dead) return;
|
||||
const amt = Number(amount) || 0;
|
||||
npc.hp = Math.max(0, npc.hp - amt);
|
||||
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
|
||||
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
|
||||
try {
|
||||
this.scene3d.floaters.spawn(
|
||||
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0));
|
||||
if (npc.hp <= 0) this._killNpc(npc);
|
||||
}
|
||||
|
||||
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
|
||||
* содержат hit-меш (или предка). Вызывает damage() → авто-floater. */
|
||||
damageByMesh(mesh, amount) {
|
||||
if (!mesh) return false;
|
||||
// 1) Быстрый путь: npcId в metadata меша (или предка).
|
||||
let m = mesh;
|
||||
for (let i = 0; i < 8 && m; i++) {
|
||||
const nid = m.metadata && m.metadata.npcId;
|
||||
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
|
||||
m = m.parent;
|
||||
}
|
||||
// 2) Fallback: сравнение с rootMesh по иерархии.
|
||||
for (const npc of this.npcs.values()) {
|
||||
if (npc.dead) continue;
|
||||
const root = npc.data && npc.data.rootMesh;
|
||||
if (!root) continue;
|
||||
let mm = mesh;
|
||||
for (let i = 0; i < 8 && mm; i++) {
|
||||
if (mm === root) { this.damage(npc.id, amount); return true; }
|
||||
mm = mm.parent;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Удалить NPC по id (без эффекта смерти — просто убрать). */
|
||||
removeNpc(id) {
|
||||
const npc = this.npcs.get(Number(id));
|
||||
|
||||
@ -3443,25 +3443,6 @@ const game = {
|
||||
* trail — шлейф за движущимся объектом.
|
||||
*/
|
||||
fx: {
|
||||
/**
|
||||
* Всплывающая цифра урона (задача 40). position — {x,y,z} или ref
|
||||
* объекта; value — число или строка; opts — color/isCrit/isHeal/isMana/
|
||||
* isMiss/fontSize/floatHeight/lifetime/randomOffset/stackKey/comicStyle.
|
||||
* game.fx.damageFloater(enemy.position, 25);
|
||||
* game.fx.damageFloater(pos, 100, { isCrit: true });
|
||||
* game.fx.damageFloater(pos, 30, { isHeal: true });
|
||||
*/
|
||||
damageFloater(position, value, opts) {
|
||||
const pos = _normFxPoint(position);
|
||||
_send('fx.damageFloater', { position: pos, value, opts: opts || {} });
|
||||
},
|
||||
/**
|
||||
* Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз — любой
|
||||
* урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true).
|
||||
*/
|
||||
autoMobFloaters(enabled, opts) {
|
||||
_send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
|
||||
},
|
||||
/**
|
||||
* Луч между двумя точками. opts: { from, to — {x,y,z} или ref
|
||||
* объекта (тогда луч следит за ним); color: '#hex', width }.
|
||||
|
||||
@ -90,18 +90,6 @@ export class WeaponSystem {
|
||||
if (e.button !== 0) return;
|
||||
// Если UI-режим курсора — не стреляем (мышь работает по GUI)
|
||||
if (this.scene3d?.player?.isUiCursorMode?.()) return;
|
||||
// Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
|
||||
// ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
|
||||
// центре экрана → используем прицел камеры (aim не задаём).
|
||||
if (document.pointerLockElement !== canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
||||
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
||||
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
||||
this.setAimScreenPoint(cx * (canvas.width / rect.width),
|
||||
cy * (canvas.height / rect.height));
|
||||
}
|
||||
}
|
||||
this._mouseDown = true;
|
||||
this._tryFire();
|
||||
};
|
||||
@ -109,28 +97,14 @@ export class WeaponSystem {
|
||||
if (e.button !== 0) return;
|
||||
this._mouseDown = false;
|
||||
};
|
||||
// При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
|
||||
// авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
|
||||
const onMove = (e) => {
|
||||
if (!this._mouseDown) return;
|
||||
if (document.pointerLockElement === canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
|
||||
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
|
||||
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
|
||||
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.code === 'KeyR') this.reload();
|
||||
};
|
||||
canvas.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('keydown', onKey);
|
||||
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
|
||||
this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
|
||||
this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
|
||||
this._listeners.push({ target: window, type: 'keydown', fn: onKey });
|
||||
|
||||
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
|
||||
@ -609,12 +583,7 @@ export class WeaponSystem {
|
||||
// (для tap-to-shoot на мобиле). Точка применяется один раз.
|
||||
let hit = null;
|
||||
let ray;
|
||||
// aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
|
||||
// только когда курсор свободен — нет pointer-lock).
|
||||
let aim = this._aimScreenPoint;
|
||||
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
|
||||
aim = this._holdAim;
|
||||
}
|
||||
const aim = this._aimScreenPoint;
|
||||
try {
|
||||
if (aim) {
|
||||
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user