237 lines
9.8 KiB
JavaScript
237 lines
9.8 KiB
JavaScript
/**
|
||
* 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!';
|
||
}
|
||
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();
|
||
}
|
||
}
|