/** * 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(); } }