diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 1efa853..0ab1acd 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -83,7 +83,8 @@ моделей для дев-панели — орбитальная камера поверх своего VoxelRenderer (текстурный слой работает), клип риги (samplePose в render, play/setTime), авто-поворот/зум/перетаскивание мышью, статы `modelStats` (воксели/ - кости/тайли) и чистая орбита `orbitPosition` — обе функции юнит-тестами; + кости/тайли), чистые орбита `orbitPosition` и центр модели `modelCenter` + (трек 33: ось вращения — центр bbox занятых вокселей) — юнит-тестами; модель без риги рисуется через dummyRig (все воксели к одному root); - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); @@ -482,7 +483,9 @@ от дневной базы к tint, фон сцены = tint, солнце гасится до 8%), `setDayAmbient` (дневная база области: тёмный интерьер затемняет hemisphere и гасит прямое солнце — аналог multiply-ambient v1; улицы — P5 по умолчанию), - `addPointLight` (three.js PointLight, distance = radius×2, decay 1.6; + `addPointLight` (three.js PointLight, distance = radius×2, decay 1.6, + интенсивность усиливается `POINT_LIGHT_GAIN = 8` — физические единицы + three; трек 33: усиление и в `setIntensity` — иначе затирается мерцанием; `PointLightHandle` setIntensity/setPos — мерцание и лампа героя двигаются на стороне игры). Игра: `src/clock.ts` (GameClock v1 1:1: 1 сек реального = 1 игровая минута, сутки 24 мин, старт 8:00) + тесты (порт clock.test v1); @@ -1253,6 +1256,30 @@ - **`spawn npx ENOENT` бывает и от несуществующего cwd** (spawn падает ENOENT не только на бинарнике — на рабочей папке). +## Трек 33 (2026-09-12): фидбек по вьюверу/свету — центр орбиты, яркие +## точечные источники, реестр объектов + +- **Ось орбиты ModelViewer — центр bbox занятых вокселей** (`modelCenter`, + `src/render/modelViewer.ts`, экспорт из движка): раньше камера крутилась + вокруг угла сетки (target [0, sy/2, 0]) — модель «ездилась» при вращении. + Пустая модель — центр размера сетки. Тест на угловые случаи. +- **Точечные источники ярче — `POINT_LIGHT_GAIN = 8` в `voxelRenderer.ts`**: + three r155+ считает PointLight в физических единицах (I/dist^decay) — при + decay 1.6 и игровом множителе ~0.9 на дистанции 5 вокселей оставалось + ~0.07 (света не видно). Грабля: **усиление затиралось мерцанием** — + `setIntensity` каждый кадр пишет «сырой» множитель игры, поэтому GAIN + обязателен и в конструкторе, и в `setIntensity` (первый кадр был ярким, + дальше — тёмным; на скриншотах выглядело как «GAIN не работает»). +- **Реестр объектов игры** (`apps/game/src/registry.ts`): `registerObject` + (дедуп по id, первый выигрывает) / `registryAll` / `registryGet`. Каталог + дев-вьювера больше не ручной список: виды мира регистрирует + `registerWorldKinds()` в areas.ts (сидит рядом с генераторами, зовётся + из buildAreaWorld), герой — main.ts при создании, NPC — в spawnNpc, + олень — в конструкторе FaunaSystem, снаряжение — рядом с GEAR_VARIANTS + (id `gear:backpack`/`gear:torch`). devview.ts только рисует DOM. + Каталог 20 записей (было 16 — добавились факел, рюкзак, бухта кабеля); + id стабильны (`char:<имя>`, `deer`, `tree0`...) — гейт panels не менялся. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index 7eb2f0f..536c085 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -9,11 +9,12 @@ */ import { VoxelWorld, VoxelGrid, stampModel, - encodeModel, Rng, type Variant, + encodeModel, Rng, resolveTiles, type Variant, } from '@rpg/engine'; import type { VoxelModel, VoxelTile, ModelTexture } from '@rpg/engine'; import mapsData from './data/maps.json'; import { texRemapFor } from './worldTex'; +import { registerObject } from './registry'; /** Раскладка области (конвертация v1): тайлы RLE, blocked/tall/decor/props v1. */ export interface AreaLayout { @@ -493,10 +494,48 @@ } /** + * Каталог дев-вьювера: виды штампуемых объектов — по одному образцу на + * вариант, сиды фиксированные (каталог не зависит от того, какой тайл + * заштампился первым). Регистрация здесь, рядом с генераторами: новый вид + * мира попадает в реестр в том же коммите, что и его генератор. + */ +let worldKindsRegistered = false; +function registerWorldKinds(): void { + if (worldKindsRegistered) return; + worldKindsRegistered = true; + const kind = (id: string, label: string, make: () => VoxelModel): void => + registerObject({ + id, label, + build: () => { + const model = make(); + return { model, tiles: resolveTiles(model) }; // скины строений — текстурный слой + }, + }); + kind('tree0', 'Дерево — круглая крона', () => mapTreeModel(0, 11)); + kind('tree1', 'Дерево — колонна', () => mapTreeModel(1, 22)); + kind('tree2', 'Ель', () => mapTreeModel(2, 33)); + kind('bush0', 'Куст', () => mapBushModel(0, 44)); + kind('bush1', 'Куст высокий', () => mapBushModel(1, 55)); + kind('bush2', 'Куст малый', () => mapBushModel(2, 66)); + kind('boulder0', 'Валун — низкий', () => mapBoulderModel(0, 77)); + kind('boulder1', 'Валун — средний', () => mapBoulderModel(1, 88)); + kind('boulder2', 'Валун — крупный', () => mapBoulderModel(2, 99)); + // 26 = HOUSE_V3 (tall); сиды 1/4/2 — стили кладка/доски/фахверк + kind('house', 'Дом — кладка', () => houseModel(0, 1)); + kind('house1', 'Дом — доски', () => houseModel(0, 4)); + kind('house2', 'Дом — фахверк, высокий', () => houseModel(26, 2)); + kind('well', 'Колодец', () => wellModel(0)); + kind('tower', 'Башня', () => towerModel(0)); + kind('machine', 'Старая Машина', () => machineModel(0)); + kind('wire', 'Бухта кабеля', () => wireModel(0)); +} + +/** * Сборка воксельного мира области из раскладки v1. Детерминировано: * пятна — хэш координат, штампы — генераторы с сидом от координат. */ export function buildAreaWorld(l: AreaLayout): VoxelWorld { + registerWorldKinds(); const world = new VoxelWorld(52); // высота чанка 52 (башня до 47 + запас) const tiles = rleDecode(l.tiles, l.width * l.height); // 1) земля (все тайлы) — потом декор, потом штампы (объекты поверх) diff --git a/v2/apps/game/src/devview.ts b/v2/apps/game/src/devview.ts index 870bafe..8e14b14 100644 --- a/v2/apps/game/src/devview.ts +++ b/v2/apps/game/src/devview.ts @@ -1,127 +1,22 @@ /** * Дев-панель игры (трек 28): список объектов сцены + вьювер движка. * Движок даёт ModelViewer (орбита мышью, клипы, статы) — панель только - * перечисляет объекты и рисует DOM. Персонажи — манекены со скинами - * (переключение палитры), природа — генераторы карты, строения целиком. + * рисует DOM. Каталог берётся из реестра объектов (registry.ts): объекты + * регистрируются там же, где создаются — devview ничего не перечисляет. * F3 (или «) — открыть/закрыть; мир стоит, пока открыта панель/вьювер. */ -import { - ModelViewer, mannequin, walkClip, resolveTiles, encodeModel, VoxelGrid, -} from '@rpg/engine'; -import type { VoxelModel, VoxelRig, AnimClip, CloudTiles, MannequinPalette, ModelStats } from '@rpg/engine'; -import { mapTreeModel, mapBushModel, mapBoulderModel, houseModel, wellModel, towerModel, machineModel } from './areas'; -import { deerVoxels } from './deer'; -import { NPCS, type NpcDef } from './data/npcs'; +import { ModelViewer, mannequin, resolveTiles } from '@rpg/engine'; +import type { MannequinPalette, ModelStats } from '@rpg/engine'; +import { registryAll, registryGet, type RegistryBuild, type RegistryEntry } from './registry'; -/** Объект вьювера: модель + рига/клип + слой тайлей + скины (палитры). */ -interface DevEntry { - id: string; - label: string; - build(): { - model: VoxelModel; - rig?: VoxelRig; - clip?: AnimClip; - tiles?: CloudTiles; - /** Скины: [имя, палитра] — переключение перекрашивает модель. */ - skins?: Array<[string, MannequinPalette]>; - }; -} - -/** Все знакомые персонажи + герой: манекены с walk-циклом и текстурой лица. */ -function characterEntries(): DevEntry[] { - const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 }); - const heroPalette: MannequinPalette = { - skin: '#c9a882', hair: '#6b4d36', torso: '#7a5a40', legs: '#4a4440', - arms: '#5f4a38', boots: '#3a3532', belt: '#8a6a3a', - }; - const skins: Array<[string, MannequinPalette]> = [ - ['Путник (герой)', heroPalette], - ...NPCS.map((n: NpcDef): [string, MannequinPalette] => [n.name, n.colors]), - ]; - return skins.map(([name, palette]) => ({ - id: `char:${name}`, - label: name, - build: () => { - const { model, rig } = mannequin(palette); - return { model, rig, clip: WALK, tiles: resolveTiles(model) }; - }, - })); -} - -/** Модель из списка вокселей [x,y,z,цвет] (палитра — по уникальным цветам). */ -function gridFromVoxels(list: Array<[number, number, number, string]>): VoxelModel { - let max = [0, 0, 0] as [number, number, number]; - for (const [x, y, z] of list) max = [Math.max(max[0], x), Math.max(max[1], y), Math.max(max[2], z)]; - const g = new VoxelGrid(max[0] + 1, max[1] + 1, max[2] + 1); - const palette: Record = {}; - let next = 1; - for (const [x, y, z, color] of list) { - let slot = Object.entries(palette).find(([, c]) => c === color)?.[0]; - if (slot === undefined) { - palette[next] = color; - slot = String(next); - next++; - } - g.set(x, y, z, Number(slot)); - } - return encodeModel(g, palette); -} - -/** Олень: модель из позы покоя deerVoxels (без риги — кубы на месте). */ -function deerEntry(): DevEntry { - return { - id: 'deer', - label: 'Олень', - build: () => { - // мировые координаты → сетка от минимальных границ - const vox = deerVoxels(0, 0, false); - const list = vox.map((v) => [Math.round(v.pos[0]), Math.round(v.pos[1]), Math.round(v.pos[2]), v.color] as [number, number, number, string]); - const xs = list.map((v) => v[0]), ys = list.map((v) => v[1]), zs = list.map((v) => v[2]); - const shift = [1 - Math.min(...xs), 1 - Math.min(...ys), 1 - Math.min(...zs)]; - const shifted = list.map(([x, y, z, c]) => [x + shift[0], y + shift[1], z + shift[2], c] as [number, number, number, string]); - return { model: gridFromVoxels(shifted) }; - }, - }; -} - -/** Природа/строения: генераторы карты по одному экземпляру. */ -function worldEntries(): DevEntry[] { - const mk = (id: string, label: string, build: () => VoxelModel): DevEntry => ({ - id, label, - build: () => { - const model = build(); - return { model, tiles: resolveTiles(model) }; // скины строений — текстурный слой - }, - }); - return [ - mk('tree0', 'Дерево — круглая крона', () => mapTreeModel(0, 11)), - mk('tree1', 'Дерево — колонна', () => mapTreeModel(1, 22)), - mk('tree2', 'Ель', () => mapTreeModel(2, 33)), - mk('bush0', 'Куст', () => mapBushModel(0, 44)), - mk('bush1', 'Куст высокий', () => mapBushModel(1, 55)), - mk('bush2', 'Куст малый', () => mapBushModel(2, 66)), - mk('boulder0', 'Валун — низкий', () => mapBoulderModel(0, 77)), - mk('boulder1', 'Валун — средний', () => mapBoulderModel(1, 88)), - mk('boulder2', 'Валун — крупный', () => mapBoulderModel(2, 99)), - mk('house', 'Дом — кладка', () => houseModel(0, 1)), - mk('house1', 'Дом — доски', () => houseModel(0, 4)), - mk('house2', 'Дом — фахверк, высокий', () => houseModel(26, 2)), // 26 = HOUSE_V3 (tall); сиды 1/4/2 — стили кладка/доски/фахверк - mk('well', 'Колодец', () => wellModel(0)), - mk('tower', 'Башня', () => towerModel(0)), - mk('machine', 'Старая Машина', () => machineModel(0)), - deerEntry(), - ]; -} - -/** Каталог дев-панели (мосту тоже нужен). */ +/** Каталог дев-панели (мосту тоже нужен): id + подпись в порядке регистрации. */ export function devEntries(): Array<{ id: string; label: string }> { - return [...characterEntries(), ...worldEntries()].map((e) => ({ id: e.id, label: e.label })); + return registryAll().map((e) => ({ id: e.id, label: e.label })); } /** Построить объект каталога (мосту). */ -export function buildDevEntry(id: string): ReturnType | null { - const all = [...characterEntries(), ...worldEntries()]; - return all.find((e) => e.id === id)?.build() ?? null; +export function buildDevEntry(id: string): RegistryBuild | null { + return registryGet(id)?.build() ?? null; } /** @@ -172,7 +67,7 @@ private playing = true; private currentSkins: Array<[string, MannequinPalette]> = []; - private currentBuild: (() => ReturnType) | null = null; + private currentEntry: RegistryEntry | null = null; /** Открыта ли панель (список или вьювер). */ get open(): boolean { @@ -218,12 +113,13 @@ /** Открыть вьювер с объектом каталога. */ openViewer(id: string): void { - const built = buildDevEntry(id); - if (!built) return; + const entry = registryGet(id); + if (!entry) return; + const built = entry.build(); this.closeViewer(); const canvas = document.getElementById('dev-canvas') as HTMLCanvasElement; this.viewer = new ModelViewer(canvas, { width: 480, height: 360, viewSize: 24 }); - this.currentBuild = () => buildDevEntry(id)!; + this.currentEntry = entry; this.currentSkins = built.skins ?? []; const select = document.getElementById('dev-skin') as HTMLSelectElement; select.replaceChildren(); @@ -263,8 +159,8 @@ /** Перекрасить модель скином i (пересборка модели + облака). */ private applySkin(i: number): void { - if (!this.viewer || !this.currentBuild) return; - const built = this.currentBuild(); + if (!this.viewer || !this.currentEntry) return; + const built = this.currentEntry.build(); const skins = built.skins ?? this.currentSkins; if (!skins || skins.length === 0) return; const palette = skins[Math.min(i, skins.length - 1)][1]; diff --git a/v2/apps/game/src/fauna.ts b/v2/apps/game/src/fauna.ts index 2a198e0..a4dfffb 100644 --- a/v2/apps/game/src/fauna.ts +++ b/v2/apps/game/src/fauna.ts @@ -5,9 +5,10 @@ * (колокольчик); боёвки в игре v2 пока нет — звон подаёт startleAt * (гейт зовёт мостом startleFauna, при переносе боя проводим от удара). */ -import { Rng, type VoxelCloud } from '@rpg/engine'; +import { Rng, encodeModel, VoxelGrid, type VoxelCloud, type VoxelModel } from '@rpg/engine'; import { UNIT, tileCenter } from './areas'; import { deerVoxels } from './deer'; +import { registerObject } from './registry'; /** Радиус слуха на звон (v1: 7 юнитов = 7 тайлов → воксели). */ const HEAR_RADIUS = 7 * UNIT; @@ -71,12 +72,42 @@ return false; } +/** Модель из списка вокселей [x,y,z,цвет] (палитра — по уникальным цветам). */ +function modelFromVoxels(list: Array<[number, number, number, string]>): VoxelModel { + let max = [0, 0, 0] as [number, number, number]; + for (const [x, y, z] of list) max = [Math.max(max[0], x), Math.max(max[1], y), Math.max(max[2], z)]; + const g = new VoxelGrid(max[0] + 1, max[1] + 1, max[2] + 1); + const palette: Record = {}; + let next = 1; + for (const [x, y, z, color] of list) { + let slot = Object.entries(palette).find(([, c]) => c === color)?.[0]; + if (slot === undefined) { + palette[next] = color; + slot = String(next); + next++; + } + g.set(x, y, z, Number(slot)); + } + return encodeModel(g, palette); +} + +/** Модель оленя для каталога: поза покоя у начала координат, сетка от + * минимальных границ (мировые координаты → локальные). */ +function deerModel(): VoxelModel { + const list = deerVoxels(0, 0, false).map((v) => + [Math.round(v.pos[0]), Math.round(v.pos[1]), Math.round(v.pos[2]), v.color] as [number, number, number, string]); + const xs = list.map((v) => v[0]), ys = list.map((v) => v[1]), zs = list.map((v) => v[2]); + const shift = [1 - Math.min(...xs), 1 - Math.min(...ys), 1 - Math.min(...zs)]; + return modelFromVoxels(list.map(([x, y, z, c]) => [x + shift[0], y + shift[1], z + shift[2], c] as [number, number, number, string])); +} + export class FaunaSystem { private deer: Deer[] = []; /** Детерминированный ГПСЧ — брожение одинаково между прогонами (v1). */ private rng = new Rng(20260908); constructor(private deps: FaunaDeps) { + registerObject({ id: 'deer', label: 'Олень', build: () => ({ model: deerModel() }) }); for (const s of deps.spawns) this.spawn(s); } diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 2dc94d8..8050564 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -29,6 +29,7 @@ import { stampProp, clearProp } from './props'; import { worldTiles as registryTiles } from './worldTex'; import { resolveCutscenes, type CutsceneAction } from './data/cutscenes'; +import { registerObject } from './registry'; import { FlowerQuest } from './flowers'; import { GameClock } from './clock'; import { LIGHTING, ATMOSPHERE, AIRFX, type Atmosphere, type LightingDef } from './data/lighting'; @@ -155,6 +156,12 @@ }); const MAN_VOXELS = hero.visual().length; // воксели тела без снаряжения +// Каталог дев-вьювера: персонажи и снаряжение — там же, где создаются +registerObject({ + id: 'char:Путник (герой)', label: 'Путник (герой)', + build: () => ({ model: MAN, rig: MAN_RIG, clip: WALK, tiles: resolveTiles(MAN) }), +}); + // ---- Снаряжение героя (трек 32, демо механизма креплений): рюкзак за // спиной на корне (едет с покачиванием шага), факел в правой кисти // (машет с рукой). Точки — в связочном пространстве манекена 10×16×4: @@ -168,6 +175,19 @@ }), } as const; export type GearId = keyof typeof GEAR_VARIANTS; +// Снаряжение — в каталоге дев-вьювера (модели без риги, слой тайлей — если есть) +for (const [id, label, make] of [ + ['gear:backpack', 'Рюкзак', backpackModel], + ['gear:torch', 'Факел', torchModel], +] as const) { + registerObject({ + id, label, + build: () => { + const model = make(); + return { model, tiles: resolveTiles(model) }; + }, + }); +} let gear: GearId[] = ['backpack', 'torch']; const applyGear = (): void => hero.setAttachments(gear.map((g) => GEAR_VARIANTS[g]())); applyGear(); @@ -294,6 +314,13 @@ function spawnNpc(def: NpcDef): NpcEntity { const c = tileCenter(def.tile.x, def.tile.y); const { model, rig } = mannequin(def.colors); // у каждого NPC свой дизайн + registerObject({ + id: `char:${def.name}`, label: def.name, + build: () => { + const m = mannequin(def.colors); + return { model: m.model, rig: m.rig, clip: WALK, tiles: resolveTiles(m.model) }; + }, + }); const char = new Character(rig, model, WALK, [c.x, c.z], { size: BODY_SIZE, speed: 0, strideLength: 9.6, }); diff --git a/v2/apps/game/src/registry.ts b/v2/apps/game/src/registry.ts new file mode 100644 index 0000000..758d064 --- /dev/null +++ b/v2/apps/game/src/registry.ts @@ -0,0 +1,42 @@ +/** + * Реестр объектов игры для дев-панели (трек 33): объекты регистрируются + * там же, где создаются (штампы мира в areas.ts, персонажи/снаряжение в + * main.ts, фауна в fauna.ts) — каталог вьювера собирается сам, без ручного + * списка в devview. Дедуп по id: штампы тысяч объектов и повторный спавн + * при переходах областей заполняют каталог по одному разу на вид. + */ +import type { VoxelModel, VoxelRig, AnimClip, CloudTiles, MannequinPalette } from '@rpg/engine'; + +/** Что строит вьювер из записи: модель + опционально рига/клип/тайли/скины. */ +export interface RegistryBuild { + model: VoxelModel; + rig?: VoxelRig; + clip?: AnimClip; + tiles?: CloudTiles; + /** Скины: [имя, палитра] — переключение перекрашивает модель. */ + skins?: Array<[string, MannequinPalette]>; +} + +/** Запись каталога: id (стабильный, для моста/гейтов), подпись, сборка. */ +export interface RegistryEntry { + id: string; + label: string; + build(): RegistryBuild; +} + +const entries = new Map(); + +/** Зарегистрировать объект вида (повторная регистрация с тем же id — no-op). */ +export function registerObject(entry: RegistryEntry): void { + if (!entries.has(entry.id)) entries.set(entry.id, entry); +} + +/** Все записи каталога в порядке регистрации. */ +export function registryAll(): RegistryEntry[] { + return [...entries.values()]; +} + +/** Запись по id (мост/гейты открывают вьювер по id). */ +export function registryGet(id: string): RegistryEntry | null { + return entries.get(id) ?? null; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 66fc65e..06ac445 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -29,7 +29,7 @@ export type { PostPassHandle } from './render/voxelRenderer'; export type { ParticlePoint, ParticleLayer } from './render/particles'; export type { CloudTiles, WorldTileAtlas } from './render/voxelRenderer'; -export { ModelViewer, modelStats, orbitPosition } from './render/modelViewer'; +export { ModelViewer, modelStats, orbitPosition, modelCenter } from './render/modelViewer'; export type { ModelStats, ModelViewerOptions } from './render/modelViewer'; export type { VoxelRendererOptions, VoxelCloud } from './render/voxelRenderer'; export { Rng } from './math/rng'; diff --git a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts index bd926dc..938494a 100644 --- a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts +++ b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { orbitPosition, modelStats } from '../modelViewer'; +import { orbitPosition, modelStats, modelCenter } from '../modelViewer'; import { mannequin } from '../../models/mannequin'; +import { VoxelGrid } from '../../voxel/grid'; +import { encodeModel } from '../../models/format'; describe('ModelViewer — чистые части (урок №3)', () => { it('орбита: yaw 0 — камера по +x, pitch 0 — на горизонтали', () => { @@ -27,4 +29,18 @@ // без риги — 0 костей expect(modelStats(model).bones).toBe(0); }); + + it('modelCenter — центр bbox занятых вокселей, не угол сетки', () => { + // куб 2×2×2, сдвинутый в угол сетки 10×10×10 — центр (1,1,1) + const g = new VoxelGrid(10, 10, 10); + for (let x = 0; x <= 1; x++) for (let y = 0; y <= 1; y++) for (let z = 0; z <= 1; z++) g.set(x, y, z, 1); + expect(modelCenter(encodeModel(g, { 1: '#fff' }))).toEqual([1, 1, 1]); + // асимметричная модель: занят x∈[4..5] — центр 2.5 по оси со сдвигом + const g2 = new VoxelGrid(6, 4, 2); + g2.set(1, 0, 0, 1); + g2.set(2, 2, 1, 1); + expect(modelCenter(encodeModel(g2, { 1: '#fff' }))).toEqual([2, 1.5, 1]); + // пустая модель — центр размера сетки + expect(modelCenter(encodeModel(new VoxelGrid(4, 6, 2), {}))).toEqual([2, 3, 1]); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/render/modelViewer.ts b/v2/packages/engine/src/render/modelViewer.ts index f938411..da265c0 100644 --- a/v2/packages/engine/src/render/modelViewer.ts +++ b/v2/packages/engine/src/render/modelViewer.ts @@ -39,6 +39,23 @@ ]; } +/** Центр bbox занятых вокселей (ось вращения вьювера — модель крутится + * вокруг своей середины, а не вокруг угла сетки). Пустая модель — центр + * размера сетки. */ +export function modelCenter(model: VoxelModel): [number, number, number] { + const g = decodeModel(model); + let min = [Infinity, Infinity, Infinity]; + let max = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i < g.data.length; i++) { + if (g.data[i] === 0) continue; + const x = i % g.sx, y = Math.floor(i / g.sx) % g.sy, z = Math.floor(i / (g.sx * g.sy)); + min = [Math.min(min[0], x), Math.min(min[1], y), Math.min(min[2], z)]; + max = [Math.max(max[0], x), Math.max(max[1], y), Math.max(max[2], z)]; + } + if (min[0] === Infinity) return [model.size[0] / 2, model.size[1] / 2, model.size[2] / 2]; + return [(min[0] + max[0] + 1) / 2, (min[1] + max[1] + 1) / 2, (min[2] + max[2] + 1) / 2]; +} + /** Числовые статы модели (занятые воксели — по декодированной сетке). */ export function modelStats(model: VoxelModel, rig?: VoxelRig): ModelStats { const grid = decodeModel(model); @@ -73,6 +90,7 @@ private rig: VoxelRig | null = null; private clip: AnimClip | null = null; private tiles: CloudTiles | null = null; + private center: [number, number, number] = [0, 4, 0]; // ось орбиты private time = 0; private playing = true; private spin = 0; // накопленный авто-поворот (рад) @@ -106,6 +124,7 @@ this.rig = opts.rig ?? null; this.clip = opts.clip ?? null; this.tiles = opts.tiles ?? null; + this.center = modelCenter(model); this.time = 0; this.cloud?.dispose(); const pose: Pose = {}; @@ -181,9 +200,9 @@ /** Камера на сферической орбите вокруг центра модели. */ private placeCamera(): void { const model = this.model; - const sy = model ? model.size[1] : 8; - const target: [number, number, number] = [0, sy / 2, 0]; - const dist = Math.max(sy * 1.6, 10) * this.zoom; + const target: [number, number, number] = model ? this.center : [0, 4, 0]; + const span = model ? Math.max(...model.size) : 10; + const dist = Math.max(span * 1.6, 10) * this.zoom; const pos = orbitPosition(this.yaw + this.spin, this.pitch, dist, target); const cam = this.renderer.camera; cam.position.set(...pos); diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index bec8b59..1e2d5ab 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -45,6 +45,12 @@ dispose(): void; } +/** Усиление интенсивности точечных источников: three r155+ считает PointLight + * в физических единицах (I/dist^decay) — при decay 1.6 на дистанции 5 вокселей + * от игрового множителя ~0.9 остаётся ~0.07 (свет невиден). GAIN применяет + * addPointLight и setIntensity (мерцание приходит «сырым» множителем игры). */ +const POINT_LIGHT_GAIN = 8; + export interface VoxelRendererOptions { /** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */ width?: number; @@ -168,14 +174,16 @@ (this.scene.background as THREE.Color).set(tint & 0xffffff); } - /** Точечный источник (не бросает тень): мерцание — через setIntensity. */ + /** Точечный источник (не бросает тень): мерцание — через setIntensity. + * Интенсивность spec'а (~0..1.5) — «множитель игры», усиливается + * POINT_LIGHT_GAIN (физические единицы three — см. константу). */ addPointLight(opts: PointLightOptions): PointLightHandle { - const light = new THREE.PointLight(opts.color, opts.intensity, opts.radius * 2, 1.6); + const light = new THREE.PointLight(opts.color, opts.intensity * POINT_LIGHT_GAIN, opts.radius * 2, 1.6); light.position.set(...opts.pos); this.scene.add(light); return { setIntensity: (i) => { - light.intensity = i; + light.intensity = i * POINT_LIGHT_GAIN; }, setPos: (p) => { light.position.set(p[0], p[1], p[2]);