feat(studio): задача 40 — damage floaters (game.fx.damageFloater)

FloaterManager.js: object pool (30 billboard-планов с DynamicTexture), tween
подъём+fade+покачивание, crit pop-scale, цвета damage/crit/heal/mana/miss,
стек одинаковых по stackKey (×N), комикс-стиль (BAM!/KAPOW!/POW! на звезде).
API game.fx.damageFloater(position, value, opts) — position {x,y,z} или ref/
'player'. Интеграция: tick в render-loop, resetRuntime при stop. Тест-игра
«Тренировочный полигон» id=2676.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-07 11:05:25 +03:00
parent f8f0d976ef
commit 458b6c3b59
4 changed files with 276 additions and 0 deletions

View File

@ -76,6 +76,7 @@ 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';
@ -1299,6 +1300,7 @@ 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();
@ -1462,6 +1464,10 @@ 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);
@ -8195,6 +8201,7 @@ 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;

View File

@ -0,0 +1,237 @@
/**
* 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();
}
}

View File

@ -1891,6 +1891,26 @@ 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;
}
// === Beam / Trail — лучи и следы (Фаза 5.2) ===
if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... }

View File

@ -3443,6 +3443,18 @@ 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 || {} });
},
/**
* Луч между двумя точками. opts: { from, to {x,y,z} или ref
* объекта (тогда луч следит за ним); color: '#hex', width }.