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:
parent
f8f0d976ef
commit
458b6c3b59
@ -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;
|
||||
|
||||
237
src/editor/engine/FloaterManager.js
Normal file
237
src/editor/engine/FloaterManager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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, ... }
|
||||
|
||||
@ -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 }.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user