Compare commits

..

10 Commits

Author SHA1 Message Date
min
2645337bdd chore: e2e-тест workflow разработчиков после восстановления
Some checks failed
CI / Lint (pull_request) Failing after 3m7s
CI / Build (pull_request) Failing after 40s
CI / Secret scan (pull_request) Failing after 29s
CI / PR size check (pull_request) Failing after 39s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-07 14:36:52 +03:00
min
48e2e83ef7 docs(studio): вики задача 40 — превью/скрин = кадр с облачками урона над зомби
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:22:11 +03:00
min
c20ac56895 docs(studio): вики задача 40 — обновлена под зомби-арену (бластер + autoMobFloaters + волны)
Карточка #64 «Зомби-арена — бластер и цифры урона» + статья переписана:
giveTool бластер, autoMobFloaters (авто-облачко над мобами), spawnNpc+follow
волны зомби, прицел в точку клика, ручной damageFloater (типы/стек/комикс).
Новые скрины scene/play (зомби-шутер).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:18:53 +03:00
min
931d53b4d9 fix(studio): бластер от 3-го лица стреляет в точку клика, а не в центр камеры
При свободном курсоре (нет pointer-lock, 3-е лицо) выстрел шёл из getForwardRay
(фокус камеры). Теперь onDown берёт координаты клика → setAimScreenPoint → луч
через точку клика; onMove обновляет _holdAim для авто-огня при удержании. При
pointer-lock (1-е лицо, курсор в центре) — прежнее поведение (центр).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:01:14 +03:00
min
e4fdd91b12 fix(studio): оружие попадает по NPC (pickable+npcId) → авто-floater урона работает
Меши NPC ставились isPickable=false → raycast бластера/меча проходил сквозь
них, урон и авто-floater не срабатывали. Теперь меши NPC pickable + npcId в
metadata; damageByMesh находит NPC по metadata (быстро) или по rootMesh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:49:06 +03:00
min
854074bfa2 feat(studio): авто-floater над мобами + урон NPC от оружия (задача 40 доп)
game.fx.autoMobFloaters(true) — включает облачка урона над NPC при любой потере
HP (NpcManager.damage). NpcManager.damageByMesh — оружие (бластер/меч) наносит
урон скриптовым NPC (weapons.setOnHit → npcManager.damageByMesh). Связка:
выстрел бластера → урон NPC → авто-floater «-N» над целью.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:41:41 +03:00
min
c93070170b docs(studio): вики задача 40 — карточка #64 + статья «Тренировочный полигон (цифры урона)»
Карточка g5 #64 guide-floaters (openProjectId 2676) + статья: game.fx.
damageFloater, типы (damage/crit/heal/mana/miss), стек stackKey, comicStyle,
object pool. 2 скрина (scene/play) в public/wiki.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:08:28 +03:00
min
458b6c3b59 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>
2026-06-07 11:05:25 +03:00
min
f8f0d976ef fix(studio): Ctrl+шорткаты не двигают камеру (Ctrl+D больше не уводит вправо)
onKeyDown клал любую клавишу в _codes (набор для WASD-движения камеры),
включая D при зажатом Ctrl → камера летела вправо при копировании. Теперь
клавиши с ctrl/meta в _codes не попадают.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:44:14 +03:00
min
6c0c3dc26e fix(studio): Ctrl+D дублирует объект РОВНО на месте оригинала (не смещает +1 по X)
duplicateSelected для model/userModel/primitive ставил копию на sel.x+1 →
визуальное смещение. Теперь копия появляется в той же точке (как Roblox Studio).
Block остаётся с поиском свободной клетки (воксель нельзя в занятую).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:38:34 +03:00
9 changed files with 474 additions and 6 deletions

View File

@ -168,3 +168,4 @@ git push origin feature/моя-фича
- Issues и PR: https://git.rublox.pro/rublox/studio - Issues и PR: https://git.rublox.pro/rublox/studio
- Безопасность: [SECURITY.md](./SECURITY.md) - Безопасность: [SECURITY.md](./SECURITY.md)
<!-- e2e-test 2026-06-07: проверка workflow разработчиков после восстановления -->

View File

@ -363,4 +363,9 @@ export const GAMES = [
desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.', desc: 'Таблица лидеров справа-сверху (монеты/время/уровень) + всплывающие достижения с редкостью и звуком. Прогресс сохраняется в БД между сессиями.',
mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'], mechanics: ['game.leaderstats.define / me.add', 'HUD-таблица топ-10 (сортировка по primary)', 'game.achievements.define / unlock', 'bindToStat — авто-награда по статy', 'toast 4 редкости + очередь', 'кубок → страница достижений', 'сохранение в БД (savegame)'],
previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true }, previewShot: 'guide-leaderstats-scene.png', openProjectId: 2616, ready: true },
{ id: 'guide-floaters', num: 64, group: 'g5', stars: 2, icon: 'sparkles',
title: 'Зомби-арена — бластер и цифры урона',
desc: 'Шутер: волны зомби бегут к игроку, бластер их отстреливает, над целью всплывают облачка урона. Авто-floater над любым мобом одной строкой + ручной game.fx.damageFloater (крит/хил/мана/промах/стек/комикс).',
mechanics: ['game.fx.damageFloater(pos, value, opts)', 'game.fx.autoMobFloaters(true) — облачко над NPC при уроне', 'game.player.giveTool(\'blaster-...\') — бластер', 'бластер от 3-го лица — в точку клика', 'spawnNpc + follow(\'player\') — зомби-волны', 'isCrit/isHeal/isMana/isMiss, стек ×N, комикс', 'object pool 30 планов (без лагов)'],
previewShot: 'guide-floaters-scene.png', openProjectId: 2676, ready: true },
]; ];

View File

@ -8782,6 +8782,91 @@ game.achievements.unlock('first_coin');`}</Code>
), ),
}, },
'guide-floaters': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Мини-шутер: волны <b>зомби бегут к игроку</b>, ты отстреливаешь
их из <b>бластера</b>, а над каждой целью всплывает <b>облачко
урона</b> как в Roblox-RPG (Pet Sim, Anime Adventures). Зомби
гибнут, счётчик растёт, волны усиливаются.
</p>
<Shot src="guide-floaters-scene.png" wide
caption="Зомби сбегаются к игроку, бластер стреляет — над целью всплывают красные облачка урона «-25»." />
<h3 className="lessonH">Шаг 1. Бластер + авто-облачка над мобами</h3>
<p>
Две строки превращают игру в шутер с фидбеком урона: выдаём
бластер и включаем <b>авто-floater</b> теперь <i>любой</i> урон
по NPC сам рисует «-N» над целью, вручную вызывать ничего не надо.
</p>
<ScriptKind kind="global" />
<Code>{`game.player.giveTool('blaster-blaster-a', { equip: true }); // бластер в руки
game.fx.autoMobFloaters(true); // облачко урона над любым мобом при попадании`}</Code>
<h3 className="lessonH">Шаг 2. Волны зомби, идущих к игроку</h3>
<Code>{`function spawnWave(n){
const pl = game.player.position;
for (let i = 0; i < n; i++){
const a = (i / n) * Math.PI * 2;
const e = game.scene.spawnNpc('skin_retro-zombie', {
x: pl.x + Math.cos(a)*18, z: pl.z + Math.sin(a)*18,
name: 'Зомби', hp: 100, speed: 2.6,
});
if (e && e.follow) e.follow('player'); // зомби преследует игрока
}
}
game.after(1.5, () => spawnWave(5));
game.every(14, () => spawnWave(8));`}</Code>
<p>
Стрелять из бластера ЛКМ. В режиме от 3-го лица пуля летит
<b> туда, куда кликнул</b> курсором. Попал по зомби облачко
урона (благодаря <code>autoMobFloaters</code>), убил засчитан.
</p>
<h3 className="lessonH">Ручной floater все типы</h3>
<p>Когда нужен полный контроль рисуй цифру сам:</p>
<Code>{`game.fx.damageFloater(pos, 25); // красный — обычный урон
game.fx.damageFloater(pos, 80, { isCrit: true }); // жёлтый, больше + подскок
game.fx.damageFloater(pos, 30, { isHeal: true }); // зелёный лечение (+30)
game.fx.damageFloater(pos, 50, { isMana: true }); // синий мана
game.fx.damageFloater(pos, 'Промах', { isMiss: true }); // серый текст`}</Code>
<p>
<b>position</b> <code>{'{x,y,z}'}</code>, ссылка на объект или
<code>'player'</code>; <b>value</b> число или строка.
</p>
<h3 className="lessonH">Стек и комикс-стиль</h3>
<Code>{`// общий stackKey → удары сливаются в «-25 ×N» вместо кучи цифр
game.fx.damageFloater(enemy.position, 25, { stackKey: 'aoe_' + enemy.id });
// comicStyle BAM! (>50), KAPOW! (>100), POW! (крит) на жёлтой звезде
game.fx.damageFloater(pos, 120, { comicStyle: true });`}</Code>
<Note>
Под капотом пул из 30 переиспользуемых билборд-планов
(object pool), поэтому даже при толпе зомби и спаме цифр FPS не
проседает. Цифры всегда поверх геометрии и повёрнуты к камере.
</Note>
<h3 className="lessonH">Почему это важно</h3>
<p>
Без облачек урона стрельба ощущается «впустую». Это базовый
боевой фидбек: игрок видит, сколько нанёс, был ли крит, попал ли.
Связка <code>бластер + autoMobFloaters + волны NPC</code> готовый
каркас любого шутера/выживания.
</p>
<Try>
Сделай «огненный» урон: <code>damageFloater(pos, 15, {'{'} color:
'#ff7a2a' {'}'})</code> каждые 0.5 сек 3 раза эффект горения.
Или увеличь HP зомби и добавь крит каждый 5-й выстрел.
</Try>
</>
),
},
}; };
/** Есть ли готовый текст урока для игры с таким id. */ /** Есть ли готовый текст урока для игры с таким id. */

View File

@ -76,6 +76,7 @@ import { Environment } from './Environment';
import { SkyboxManager } from './SkyboxManager'; import { SkyboxManager } from './SkyboxManager';
import { LeaderstatsManager } from './LeaderstatsManager'; import { LeaderstatsManager } from './LeaderstatsManager';
import { AchievementsManager } from './AchievementsManager'; import { AchievementsManager } from './AchievementsManager';
import { FloaterManager } from './FloaterManager'; // задача 40 — damage floaters
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
import { GameAudioManager } from './GameAudioManager'; import { GameAudioManager } from './GameAudioManager';
import { AssetManager } from './AssetManager'; import { AssetManager } from './AssetManager';
@ -1299,6 +1300,7 @@ export class BabylonScene {
this.dynamics = new DynamicsManager(this); this.dynamics = new DynamicsManager(this);
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight); this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
this.skybox = new SkyboxManager(this.scene, this._hemiLight, this._sunLight); // задача 16 — кастомное небо (единый источник света) 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.leaderstats = new LeaderstatsManager(this); // задача 20 — лидерборды
this.achievements = new AchievementsManager(this); // задача 20 — достижения this.achievements = new AchievementsManager(this); // задача 20 — достижения
this.audioManager = new AudioManager(); this.audioManager = new AudioManager();
@ -1462,6 +1464,10 @@ export class BabylonScene {
if (this._isPlaying && this.leaderstats) { if (this._isPlaying && this.leaderstats) {
this.leaderstats.tick(); this.leaderstats.tick();
} }
// Damage floaters (задача 40) — анимация всплывающих цифр.
if (this.floaters) {
this.floaters.tick(dt);
}
// Анимация жидкостей — работает всегда (и в редакторе) // Анимация жидкостей — работает всегда (и в редакторе)
if (this.blockManager) { if (this.blockManager) {
this.blockManager.tick(dt); this.blockManager.tick(dt);
@ -2565,7 +2571,9 @@ export class BabylonScene {
const onKeyDown = (e) => { const onKeyDown = (e) => {
if (isTypingTarget(e.target)) return; if (isTypingTarget(e.target)) return;
this._codes.add(e.code); // Клавиши с Ctrl/Cmd — это шорткаты (Ctrl+D/C/V/Z...), а не движение
// камеры. Не кладём их в _codes, иначе камера «уезжает» (баг Ctrl+D).
if (!e.ctrlKey && !e.metaKey) this._codes.add(e.code);
if (e.shiftKey) this._shiftDown = true; if (e.shiftKey) this._shiftDown = true;
// Маршрутизация game.onKey в Play-режиме // Маршрутизация game.onKey в Play-режиме
if (this._isPlaying && this.gameRuntime) { if (this._isPlaying && this.gameRuntime) {
@ -5380,7 +5388,8 @@ export class BabylonScene {
const sx = sel.x, sy = sel.y, sz = sel.z; const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0; const rotY = sel.rotationY || 0;
const srcId = sel.instanceId; const srcId = sel.instanceId;
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) // Дубль появляется РОВНО на месте оригинала (как в Roblox Studio).
this.modelManager.addInstance(typeId, sx, sy, sz, rotY)
.then(newId => { .then(newId => {
if (newId != null) { if (newId != null) {
this._copyScriptsToNewObject('model', srcId, newId); this._copyScriptsToNewObject('model', srcId, newId);
@ -5396,7 +5405,7 @@ export class BabylonScene {
const sx = sel.x, sy = sel.y, sz = sel.z; const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0; const rotY = sel.rotationY || 0;
const srcUmId = sel.instanceId; const srcUmId = sel.instanceId;
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { this.userModelManager.addInstance(typeId, sx, sy, sz, rotY, {
currentUserId: this._currentUserId || null, currentUserId: this._currentUserId || null,
}).then(newId => { }).then(newId => {
if (newId != null) { if (newId != null) {
@ -5408,7 +5417,7 @@ export class BabylonScene {
}); });
} else if (sel.type === 'primitive') { } else if (sel.type === 'primitive') {
const newId = this.primitiveManager.addInstance(sel.primitiveType, { const newId = this.primitiveManager.addInstance(sel.primitiveType, {
x: sel.x + 1, y: sel.y, z: sel.z, x: sel.x, y: sel.y, z: sel.z,
sx: sel.sx, sy: sel.sy, sz: sel.sz, sx: sel.sx, sy: sel.sy, sz: sel.sz,
// Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04). // Сохраняем вращение копии (без этого сбрасывалось, баг 2026-06-04).
rotationX: sel.rotationX || 0, rotationX: sel.rotationX || 0,
@ -6220,6 +6229,10 @@ export class BabylonScene {
if (hit?.mesh && this.zombieManager) { if (hit?.mesh && this.zombieManager) {
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
} }
// Урон скриптовым NPC (киты-враги) → авто-floater над мобом (задача 40).
if (hit?.mesh && this.npcManager) {
try { this.npcManager.damageByMesh(hit.mesh, hit.damage || 25); } catch (e) {}
}
if (this._onWeaponHit) { if (this._onWeaponHit) {
try { this._onWeaponHit(hit); } catch (e) {} try { this._onWeaponHit(hit); } catch (e) {}
} }
@ -8192,6 +8205,7 @@ export class BabylonScene {
// Задача 20: чистим рантайм лидербордов/достижений (определения остаются). // Задача 20: чистим рантайм лидербордов/достижений (определения остаются).
try { this.leaderstats?.resetRuntime?.(); } catch (e) {} try { this.leaderstats?.resetRuntime?.(); } catch (e) {}
try { this.achievements?.resetRuntime?.(); } catch (e) {} try { this.achievements?.resetRuntime?.(); } catch (e) {}
try { this.floaters?.resetRuntime?.(); } catch (e) {} // задача 40
// Сбрасываем таймер прохождения // Сбрасываем таймер прохождения
this._timerRunning = false; this._timerRunning = false;
this._timerStartedAt = null; 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,35 @@ export class GameRuntime {
if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; } if (cmd === 'loading.setCover') { this.scene3d?.loadingScreen?.setCover(payload?.cover); return; }
if (cmd === 'loading.close') { this.scene3d?.loadingScreen?.close(); 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;
}
if (cmd === 'fx.autoMobFloaters') {
try {
if (this.scene3d?.npcManager) {
this.scene3d.npcManager._autoFloater = payload?.enabled
? { opts: payload?.opts || {} } : null;
}
} catch (e) { /* ignore */ }
return;
}
// === Beam / Trail — лучи и следы (Фаза 5.2) === // === Beam / Trail — лучи и следы (Фаза 5.2) ===
if (cmd === 'fx.create') { if (cmd === 'fx.create') {
// payload: { kind: 'beam'|'trail', localRef, ... } // payload: { kind: 'beam'|'trail', localRef, ... }

View File

@ -161,6 +161,20 @@ export class NpcManager {
r15Animator, r15Animator,
}; };
this.npcs.set(id, npc); this.npcs.set(id, npc);
// Пометить меши NPC для попаданий оружия (бластер/меч): pickable + npcId
// в metadata. Без pickable raycast оружия проходит сквозь NPC и урон/
// floater'ы не срабатывают (задача 40).
try {
const root = npc.data && npc.data.rootMesh;
if (root) {
root.isPickable = true;
root.metadata = Object.assign({}, root.metadata, { npcId: id });
for (const m of root.getChildMeshes(false)) {
m.isPickable = true;
m.metadata = Object.assign({}, m.metadata, { npcId: id });
}
}
} catch (e) { /* ignore */ }
return id; return id;
} }
@ -292,10 +306,43 @@ export class NpcManager {
damage(id, amount) { damage(id, amount) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));
if (!npc || npc.dead) return; if (!npc || npc.dead) return;
npc.hp = Math.max(0, npc.hp - (Number(amount) || 0)); const amt = Number(amount) || 0;
npc.hp = Math.max(0, npc.hp - amt);
// Авто-floater над мобом (задача 40 доп): game.fx.autoMobFloaters(true).
if (this._autoFloater && amt > 0 && this.scene3d?.floaters) {
try {
this.scene3d.floaters.spawn(
{ x: npc.x, y: (npc.y || 0) + 2.2, z: npc.z }, amt, this._autoFloater.opts || {});
} catch (e) { /* ignore */ }
}
if (npc.hp <= 0) this._killNpc(npc); if (npc.hp <= 0) this._killNpc(npc);
} }
/** Нанести урон NPC по мешу-попаданию (бластер/оружие). Ищет NPC, чьи меши
* содержат hit-меш (или предка). Вызывает damage() авто-floater. */
damageByMesh(mesh, amount) {
if (!mesh) return false;
// 1) Быстрый путь: npcId в metadata меша (или предка).
let m = mesh;
for (let i = 0; i < 8 && m; i++) {
const nid = m.metadata && m.metadata.npcId;
if (nid != null && this.npcs.has(nid)) { this.damage(nid, amount); return true; }
m = m.parent;
}
// 2) Fallback: сравнение с rootMesh по иерархии.
for (const npc of this.npcs.values()) {
if (npc.dead) continue;
const root = npc.data && npc.data.rootMesh;
if (!root) continue;
let mm = mesh;
for (let i = 0; i < 8 && mm; i++) {
if (mm === root) { this.damage(npc.id, amount); return true; }
mm = mm.parent;
}
}
return false;
}
/** Удалить NPC по id (без эффекта смерти — просто убрать). */ /** Удалить NPC по id (без эффекта смерти — просто убрать). */
removeNpc(id) { removeNpc(id) {
const npc = this.npcs.get(Number(id)); const npc = this.npcs.get(Number(id));

View File

@ -3443,6 +3443,25 @@ const game = {
* trail шлейф за движущимся объектом. * trail шлейф за движущимся объектом.
*/ */
fx: { 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 || {} });
},
/**
* Авто-floater'ы над мобами (NPC) при потере HP. Включил один раз любой
* урон по NPC сам показывает облачко «-N». game.fx.autoMobFloaters(true).
*/
autoMobFloaters(enabled, opts) {
_send('fx.autoMobFloaters', { enabled: enabled !== false, opts: opts || {} });
},
/** /**
* Луч между двумя точками. opts: { from, to {x,y,z} или ref * Луч между двумя точками. opts: { from, to {x,y,z} или ref
* объекта (тогда луч следит за ним); color: '#hex', width }. * объекта (тогда луч следит за ним); color: '#hex', width }.

View File

@ -90,6 +90,18 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
// Если UI-режим курсора — не стреляем (мышь работает по GUI) // Если UI-режим курсора — не стреляем (мышь работает по GUI)
if (this.scene3d?.player?.isUiCursorMode?.()) return; if (this.scene3d?.player?.isUiCursorMode?.()) return;
// Если курсор СВОБОДЕН (нет pointer-lock — обычно 3-е лицо) — стреляем
// ТУДА, КУДА КЛИКНУЛИ, а не в центр камеры. При pointer-lock курсор в
// центре экрана → используем прицел камеры (aim не задаём).
if (document.pointerLockElement !== canvas) {
const rect = canvas.getBoundingClientRect();
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
this.setAimScreenPoint(cx * (canvas.width / rect.width),
cy * (canvas.height / rect.height));
}
}
this._mouseDown = true; this._mouseDown = true;
this._tryFire(); this._tryFire();
}; };
@ -97,14 +109,28 @@ export class WeaponSystem {
if (e.button !== 0) return; if (e.button !== 0) return;
this._mouseDown = false; this._mouseDown = false;
}; };
// При свободном курсоре (3-е лицо) запоминаем позицию мыши — чтобы
// авто-огонь при удержании ЛКМ продолжал стрелять в точку курсора.
const onMove = (e) => {
if (!this._mouseDown) return;
if (document.pointerLockElement === canvas) return;
const rect = canvas.getBoundingClientRect();
const cx = (e.clientX != null ? e.clientX : 0) - rect.left;
const cy = (e.clientY != null ? e.clientY : 0) - rect.top;
if (cx >= 0 && cy >= 0 && cx <= rect.width && cy <= rect.height) {
this._holdAim = { x: cx * (canvas.width / rect.width), y: cy * (canvas.height / rect.height) };
}
};
const onKey = (e) => { const onKey = (e) => {
if (e.code === 'KeyR') this.reload(); if (e.code === 'KeyR') this.reload();
}; };
canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousedown', onDown);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown }); this._listeners.push({ target: canvas, type: 'mousedown', fn: onDown });
this._listeners.push({ target: window, type: 'mouseup', fn: onUp }); this._listeners.push({ target: window, type: 'mouseup', fn: onUp });
this._listeners.push({ target: window, type: 'mousemove', fn: onMove });
this._listeners.push({ target: window, type: 'keydown', fn: onKey }); this._listeners.push({ target: window, type: 'keydown', fn: onKey });
// Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true) // Регистрируем перед-кадровый хук для авто-стрельбы (если auto=true)
@ -583,7 +609,12 @@ export class WeaponSystem {
// (для tap-to-shoot на мобиле). Точка применяется один раз. // (для tap-to-shoot на мобиле). Точка применяется один раз.
let hit = null; let hit = null;
let ray; let ray;
const aim = this._aimScreenPoint; // aim: разовый клик (_aimScreenPoint) или удержание по курсору (_holdAim,
// только когда курсор свободен — нет pointer-lock).
let aim = this._aimScreenPoint;
if (!aim && this._holdAim && document.pointerLockElement !== this.scene3d?.canvas) {
aim = this._holdAim;
}
try { try {
if (aim) { if (aim) {
ray = this.scene.createPickingRay(aim.x, aim.y, null, camera); ray = this.scene.createPickingRay(aim.x, aim.y, null, camera);