From 458b6c3b59dfb66b8b24e19b68357728646a3c27 Mon Sep 17 00:00:00 2001 From: min Date: Sun, 7 Jun 2026 11:05:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(studio):=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=2040=20=E2=80=94=20damage=20floaters=20(game.fx.damageFl?= =?UTF-8?q?oater)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/editor/engine/BabylonScene.js | 7 + src/editor/engine/FloaterManager.js | 237 +++++++++++++++++++++++ src/editor/engine/GameRuntime.js | 20 ++ src/editor/engine/ScriptSandboxWorker.js | 12 ++ 4 files changed, 276 insertions(+) create mode 100644 src/editor/engine/FloaterManager.js diff --git a/src/editor/engine/BabylonScene.js b/src/editor/engine/BabylonScene.js index c073550..d6f7e0d 100644 --- a/src/editor/engine/BabylonScene.js +++ b/src/editor/engine/BabylonScene.js @@ -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; diff --git a/src/editor/engine/FloaterManager.js b/src/editor/engine/FloaterManager.js new file mode 100644 index 0000000..749764c --- /dev/null +++ b/src/editor/engine/FloaterManager.js @@ -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(); + } +} diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6a9bd71..8290234 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -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, ... } diff --git a/src/editor/engine/ScriptSandboxWorker.js b/src/editor/engine/ScriptSandboxWorker.js index 298ea6d..d7498fb 100644 --- a/src/editor/engine/ScriptSandboxWorker.js +++ b/src/editor/engine/ScriptSandboxWorker.js @@ -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 }.