diff --git a/docs/plan.md b/docs/plan.md index b0019cf..aee9563 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -858,3 +858,20 @@ и «пилили» силуэт). Олень без изменений: четверти оборота держат сетку на осях. Проба `apps/game/tools/probe-diag.mjs` (диагональ, yaw 3π/4). 6 гейтов игры + 11 демок зелёные, 301 тест. + +- **Трек 28** (2026-09-11, «лоад-скрин, сейвы, инвентарь; баг — застревание + при выходе из дома; дев-панель с просмотром объектов»): пять подзадач. + Баг: дома штампуются шире тайла — тайл входа мог быть внутри стен; + `safeEntry` в main.ts ищет ближайшую свободную позицию спиралью + (`collides`). Лоад-скрин `#loading` до первого кадра. Слоты сейвов в + меню: auto + 3 ручных, сохранить/загрузить/удалить, мета с временем + (мост `menuInfo.slots`, `saveTo/loadFrom/saveDelete`). Панель сумки + по I (`#bag`, мир стоит; мост `bagInfo`/`toggleBag`). **Движок — + `ModelViewer`** (`src/render/modelViewer.ts`): орбитальная камера + поверх VoxelRenderer, клипы риги, turntable, зум, статы `modelStats` + (юнит-тесты чистой орбиты и статов). Дев-панель игры (F3, `devview.ts`): + каталог 14 объектов (персонажи со скинами, природа, строения, олень), + вьювер с паузой клипа и селектом скинов; мир стоит. Гейт + `apps/game/tools/checks/panels.mjs` (21 проверка). Открытый вопрос: + эффекты у объекта в вьювере (частицы/свет) — не делались, отложено. + 6+1 гейтов игры + 11 демок зелёные, 303 теста. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 75f076c..a1655ac 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -55,6 +55,7 @@ node apps/game/tools/checks/atmosphere.mjs # гейт времени суток/света/музыки игры (/tmp/v2_atmosphere_*.png) node apps/game/tools/checks/act2.mjs # гейт акта 2: резонатор/Машина/олени (/tmp/v2_act2_*.png) node apps/game/tools/checks/cutscenes.mjs # гейт катсцен: dry-run сцен, камера к герою (/tmp/v2_cutscene_*.png) +node apps/game/tools/checks/panels.mjs # гейт панелей UI: лоад-скрин, слоты, сумка, дев-вьювер (/tmp/v2_panels_*.png) node apps/game/tools/compare-shot.mjs --dir /tmp # сравнительные снимки с v1 (5 участков, пары к v1/apps/game/tools/compare-shot.mjs) node apps/game/tools/shot.mjs --area rust_grove --at 24,16 --flags act2_hook --out /tmp/s.png # оперативный скриншот участка карты агентом @@ -73,6 +74,12 @@ чанков (get/set через границы, dirty-трекинг: правка боковой кромки помечает соседа); `src/world/worldMesher.ts` — `meshChunk` через `world.get`, шов чанков бесшовный (culled и AO через границы); + - `src/render/modelViewer.ts` — `ModelViewer` (трек 28): просмотрщик + моделей для дев-панели — орбитальная камера поверх своего VoxelRenderer + (текстурный слой работает), клип риги (samplePose в render, play/setTime), + авто-поворот/зум/перетаскивание мышью, статы `modelStats` (воксели/ + кости/тайли) и чистая орбита `orbitPosition` — обе функции юнит-тестами; + модель без риги рисуется через dummyRig (все воксели к одному root); - `src/render/voxelRenderer.ts` — three.js-бэкенд: ортокамера диметрии 2:1, dirLight + shadow map + hemisphere, низкое разрешение (480×270); `addVoxelCloud` — instanced-кубы для поз анимации (не выровнены по @@ -924,6 +931,57 @@ зелёные, 301 тест; визуальная проба `tools/probe-diag.mjs` (герой на мировой диагонали, yaw 3π/4 — силуэт чистый). +## Трек 28 (2026-09-11): лоад-скрин, слоты сейвов, панель сумки, баг дома, дев-вьювер + +Пять подзадач фидбека; движковая часть — `ModelViewer`, игровая — панели UI. + +- **Баг «застревание при выходе из дома»**: дома/башня штампуются шире + своего тайла (дом 24×18 ≈ 2.4×1.8 тайла), тайл входа мог оказаться + внутри стен — герой рождался зажатым в доме. `safeEntry(c)` в main.ts: + если позиция пересекается с миром (`collides(world, pos, BODY_SIZE)`), + спиральный поиск ближайшей свободной с шагом 2 вокс до 3 тайлов; + применяется в setArea. Проба `tools/probe-house.mjs` (вход в дом → + выход через триггер → герой на свободной позиции вне стен). Грабля + пробы: **стик (0,−1) — это мировая диагональ**, чистое −z — стик + (1,−1); и герой успел уйти с карты (интерьер без бортов — падение + с y=−5.2): держать стик до смены области, не фиксированное число тиков. +- **Лоад-скрин**: `#loading` (index.html, z-index 20 поверх меню) — + скрыт после первого кадра рендера в main.ts. +- **Слоты сейвов в меню**: `#slots` (auto + s1..s3), строки с метой + (заголовок области + время локалью), кнопки сохранить/загрузить/ + удалить; «Сохранить» перерисовывает список. Мост: `menuInfo` расширен + строками слотов, `saveTo/loadFrom/saveDelete` (как кнопки). +- **Панель сумки (I)**: `#bag` по центру (названия предметов из + `itemName`, «Пусто.»), мир стоит (ранний return в tick), Esc/E + под сумкой закрывают/глотаются. Мост: `bagInfo`, `toggleBag`. +- **Движок — `ModelViewer`** (`src/render/modelViewer.ts`): просмотрщик + моделей для дев-панели. Орбитальная камера поверх своего VoxelRenderer + (тот же текстурный слой: `addVoxelCloud(voxels, resolveTiles(model))`), + клип риги воспроизводится (samplePose в render, play/setTime), + авто-поворот (setSpin, turntable), зум (setZoom), перетаскивание + мышью (pointer events на канвасе), статы `modelStats` (воксели/ + кости/тайли — по декодированной сетке, юнит-тест), чистая орбита + `orbitPosition` (юнит-тест). Модель без риги — dummyRig (все воксели + к одному root). Экспорт из движка: `ModelViewer`, `modelStats`, + `orbitPosition`, `ModelStats`, `ModelViewerOptions`, `CloudTiles`. +- **Дев-панель игры** (`src/devview.ts`): F3 (или «) — список объектов + (14: герой и 3 NPC манекенами со скинами, дерево ×3/куст ×2/валун, + дом/колодец/башня/Машина, олень), клик — вьювер-оверлей `#devview` + (канвас + статы + ⏸/⏵ клипа + чекбокс поворота + селект скинов — + перекраска манекена другой палитрой «посмотреть скин»). Мир стоит, + пока открыта панель/вьювер (как в диалоге). Мост: `devInfo` + (каталог), `devOpen`/`devOpenEntry(id)`/`devClose`, `devStats`. + Олень — VoxelModel из `deerVoxels` покоя (`gridFromVoxels`: список + [x,y,z,цвет] → сетка с палитрой по уникальным цветам). +- Гейт `tools/checks/panels.mjs` (21 проверка — 17-й гейт игры): + лоад-скрин, 4 слота + автосейв, saveTo/loadFrom/saveDelete, + восстановление позиции, сумка (мир стоит, «Пусто.»), каталог ≥14, + статы манекена/оленечья, кадр вьювера не пустой, закрытие. + Все 6 старых гейтов игры + 11 демок зелёные, **303 теста** + (+2: орбита и статы ModelViewer). +- Эффекты в вьювере (частицы/свет у объекта) — не делались, открытый + вопрос отложен (см. plan.md). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html index cf94b79..5f6959e 100644 --- a/v2/apps/game/index.html +++ b/v2/apps/game/index.html @@ -129,6 +129,85 @@ } #dialog .choices button:hover { border-color: #d99a32; color: #fff; } #dialog .continue { margin-top: 8px; color: #626c4c; font-size: 13px; } + /* Панель сумки (клавиша I): центр экрана, список предметов */ + #bag { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 380px; + max-width: 90vw; + padding: 14px 18px; + background: rgba(23, 23, 29, 0.94); + color: #c9c9d2; + font: 15px/1.6 monospace; + border: 1px solid #44454e; + border-radius: 6px; + z-index: 12; + } + #bag .title { color: #d99a32; font-weight: bold; letter-spacing: 2px; margin-bottom: 6px; } + #bag .item { color: #c9c9d2; } + #bag .foot { margin-top: 10px; color: #626c4c; font-size: 12px; } + /* Дев-панель (F3): список объектов справа + вьювер-оверлей */ + #dev { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 260px; + padding: 12px; + background: rgba(23, 23, 29, 0.95); + border-left: 1px solid #44454e; + z-index: 14; + font: 14px monospace; + overflow-y: auto; + } + #dev h2 { margin: 0 0 8px; color: #d99a32; font-size: 15px; letter-spacing: 1px; } + #dev-list { display: flex; flex-direction: column; gap: 4px; } + #dev-list button { + text-align: left; + padding: 5px 8px; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 3px; + font: inherit; + cursor: pointer; + } + #dev-list button:hover { border-color: #d99a32; color: #fff; } + #devview { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(10, 10, 14, 0.9); + z-index: 15; + } + #devview[hidden] { display: none; } + #dev-canvas { + width: min(80vw, 720px); + height: auto; + image-rendering: pixelated; + border: 1px solid #44454e; + background: #1b1b22; + cursor: grab; + touch-action: none; + } + #dev-stats { color: #626c4c; font: 13px monospace; } + #dev-controls { display: flex; gap: 8px; align-items: center; } + #dev-controls button, #dev-controls select { + padding: 4px 10px; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 3px; + font: 13px monospace; + cursor: pointer; + } + #dev-controls button:hover { border-color: #d99a32; color: #fff; } /* Меню: Esc — пауза с автосейвом; старт страницы — выбор входа */ #menu { position: fixed; @@ -142,6 +221,21 @@ z-index: 10; } #menu[hidden] { display: none; } /* display:flex перебивает атрибут hidden */ + /* Лоад-скрин: до первого отрисованного кадра (поверх меню) */ + #loading { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: #17171d; + z-index: 20; + color: #d99a32; + font: 18px monospace; + letter-spacing: 2px; + } + #loading[hidden] { display: none; } + #loading .dots::after { content: '…'; } #menu h1 { margin: 0 0 8px; color: #d99a32; @@ -161,10 +255,43 @@ #menu button:hover:not(:disabled) { border-color: #d99a32; color: #fff; } #menu button:disabled { opacity: 0.4; cursor: default; } #menu p { margin-top: 10px; color: #626c4c; font: 13px monospace; } + /* Слоты сейвов: список строк под кнопками входа */ + #slots { + width: 480px; + max-width: 92vw; + display: flex; + flex-direction: column; + gap: 6px; + } + #slots .slot { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: #1b1b22; + border: 1px solid #33343c; + border-radius: 4px; + font: 13px monospace; + } + #slots .slot .name { flex: 1; text-align: left; color: #c9c9d2; } + #slots .slot .name .when { color: #626c4c; display: block; font-size: 12px; } + #slots .slot button { + padding: 3px 8px; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 3px; + font: 12px monospace; + cursor: pointer; + } + #slots .slot button:hover:not(:disabled) { border-color: #d99a32; color: #fff; } + #slots .slot button:disabled { opacity: 0.35; cursor: default; } + #slots h2 { margin: 8px 0 0; color: #626c4c; font: 14px monospace; letter-spacing: 1px; } +
Загрузка
@@ -173,10 +300,28 @@
+ + + diff --git a/v2/apps/game/src/devview.ts b/v2/apps/game/src/devview.ts new file mode 100644 index 0000000..52fd8a1 --- /dev/null +++ b/v2/apps/game/src/devview.ts @@ -0,0 +1,243 @@ +/** + * Дев-панель игры (трек 28): список объектов сцены + вьювер движка. + * Движок даёт ModelViewer (орбита мышью, клипы, статы) — панель только + * перечисляет объекты и рисует DOM. Персонажи — манекены со скинами + * (переключение палитры), природа — генераторы карты, строения целиком. + * 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'; + +/** Объект вьювера: модель + рига/клип + слой тайлей + скины (палитры). */ +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: () => ({ model: build() }) }); + 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('boulder', 'Валун', () => mapBoulderModel(0, 66)), + mk('house', 'Дом', () => houseModel(0, 0)), + mk('well', 'Колодец', () => wellModel(0)), + mk('tower', 'Башня', () => towerModel(0)), + mk('machine', 'Старая Машина', () => machineModel(0)), + deerEntry(), + ]; +} + +/** Каталог дев-панели (мосту тоже нужен). */ +export function devEntries(): Array<{ id: string; label: string }> { + return [...characterEntries(), ...worldEntries()].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; +} + +/** + * Панель: DOM-список (#dev) + вьювер-оверлей (#devview). onOpenChange — + * игра ставит мир на паузу (как в диалоге). + */ +export class DevPanel { + private viewer: ModelViewer | null = null; + private raf = 0; + private last = 0; + private spinOn = false; + + constructor( + private readonly onOpenChange: (open: boolean) => void, + ) { + document.getElementById('dev-open')!.addEventListener('click', () => this.closeViewer()); + const playBtn = document.getElementById('dev-play')!; + playBtn.addEventListener('click', () => { + this.playing = !this.playing; + this.viewer?.play(this.playing); + playBtn.textContent = this.playing ? '⏸' : '⏵'; + }); + document.getElementById('dev-spin')!.addEventListener('change', (e) => { + this.spinOn = (e.target as HTMLInputElement).checked; + this.viewer?.setSpin(this.spinOn ? 0.8 : 0); + }); + document.getElementById('dev-skin')!.addEventListener('change', (e) => { + const i = Number((e.target as HTMLSelectElement).value); + this.applySkin(i); + }); + } + + private playing = true; + private currentSkins: Array<[string, MannequinPalette]> = []; + private currentBuild: (() => ReturnType) | null = null; + + /** Открыта ли панель (список или вьювер). */ + get open(): boolean { + const el = document.getElementById('dev'); + return el !== null && !el.hidden; + } + + /** Показать список объектов. */ + openPanel(): void { + document.getElementById('dev')!.hidden = false; + const list = document.getElementById('dev-list')!; + list.replaceChildren(); + for (const e of devEntries()) { + const btn = document.createElement('button'); + btn.textContent = e.label; + btn.addEventListener('click', () => this.openViewer(e.id)); + list.appendChild(btn); + } + this.onOpenChange(true); + } + + /** Закрыть всё (панель и вьювер). */ + close(): void { + this.closeViewer(); + document.getElementById('dev')!.hidden = true; + this.onOpenChange(false); + } + + /** Статы текущего объекта вьювера (мост/гейт). */ + stats(): ModelStats | null { + return this.viewer?.stats() ?? null; + } + + /** Открыть вьювер с объектом каталога. */ + openViewer(id: string): void { + const built = buildDevEntry(id); + if (!built) return; + 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.currentSkins = built.skins ?? []; + const select = document.getElementById('dev-skin') as HTMLSelectElement; + select.replaceChildren(); + select.hidden = (this.currentSkins?.length ?? 0) < 2; + (this.currentSkins ?? []).forEach(([name], i) => { + const opt = document.createElement('option'); + opt.textContent = name; + opt.value = String(i); + select.appendChild(opt); + }); + this.applySkin(0); // скины: перекрасит; без скинов — модель напрямую + if (!built.skins) this.viewer.setModel(built.model, { rig: built.rig, clip: built.clip, tiles: built.tiles }); + (document.getElementById('dev-play') as HTMLButtonElement).textContent = '⏸'; + (document.getElementById('dev-spin') as HTMLInputElement).checked = false; + this.spinOn = false; + (document.getElementById('dev-stats') as HTMLDivElement).textContent = this.statText(); + document.getElementById('devview')!.hidden = false; + this.playing = true; + this.last = performance.now(); + const step = (now: number): void => { + if (!this.viewer) return; // вьювер закрыт — цикл закончен + this.viewer.render(Math.min(0.1, (now - this.last) / 1000)); + this.last = now; + this.raf = requestAnimationFrame(step); + }; + this.raf = requestAnimationFrame(step); + } + + /** Закрыть вьювер (панель списка остаётся). */ + private closeViewer(): void { + if (!this.viewer) return; + cancelAnimationFrame(this.raf); + this.viewer.dispose(); + this.viewer = null; + document.getElementById('devview')!.hidden = true; + } + + /** Перекрасить модель скином i (пересборка модели + облака). */ + private applySkin(i: number): void { + if (!this.viewer || !this.currentBuild) return; + const built = this.currentBuild(); + const skins = built.skins ?? this.currentSkins; + if (!skins || skins.length === 0) return; + const palette = skins[Math.min(i, skins.length - 1)][1]; + const { model, rig } = mannequin(palette); + this.viewer.setModel(model, { rig, clip: built.clip, tiles: resolveTiles(model) }); + } + + /** Статы для подписи под вьювером. */ + private statText(): string { + const s = this.viewer?.stats(); + return s ? `${s.voxels} вокс · ${s.size.join('×')} · костей: ${s.bones} · тайлей: ${s.tiles}` : ''; + } +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 5b88d91..c85dd68 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -10,7 +10,7 @@ VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, Inventory, CutsceneRunner, validateCutscenes, - resolveTiles, + resolveTiles, collides, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, @@ -39,6 +39,7 @@ import { GameAudio } from './audio'; import { FaunaSystem } from './fauna'; import { ScreenFx } from './screenFx'; +import { DevPanel, devEntries } from './devview'; // Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17) const PALETTE = { @@ -315,6 +316,25 @@ }; /** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */ +/** + * Точка входа в область без застревания: дома/башня штампуются шире своего + * тайла (дом 24×18 ≈ 2.4×1.8 тайла), тайл входа может оказаться внутри + * стен — герой рождался зажатым в доме. Если позиция пересекается с миром, + * ищем ближайшую свободную спиралью с шагом 2 вокс (до 3 тайлов). + */ +function safeEntry(c: { x: number; z: number }): [number, number] { + if (!collides(world, [c.x, GROUND_Y, c.z], BODY_SIZE)) return [c.x, c.z]; + for (let r = 2; r <= UNIT * 3; r += 2) { + for (let dx = -r; dx <= r; dx += 2) { + for (const dz of [r, -r]) { + if (!collides(world, [c.x + dx, GROUND_Y, c.z + dz], BODY_SIZE)) return [c.x + dx, c.z + dz]; + if (!collides(world, [c.x + dz, GROUND_Y, c.z + dx], BODY_SIZE)) return [c.x + dz, c.z + dx]; + } + } + } + return [c.x, c.z]; // не нашли (не должно случиться) — как было +} + function setArea(id: string, entry: Tile): void { currentArea = areaOf(id); layout = AREA_LAYOUTS[currentArea.id]!; @@ -326,7 +346,8 @@ stampAreaProps(); spawnNpcs(); const c = tileCenter(entry.x, entry.y); - hero.body.pos = [c.x, GROUND_Y, c.z]; + const [ex, ez] = safeEntry(c); + hero.body.pos = [ex, GROUND_Y, ez]; hero.body.vel = [0, 0, 0]; heroCloud.update(hero.visual()); prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера) @@ -531,6 +552,7 @@ /** Нажатие «E»: диалог → NPC → click-переход → интерактив → цветок (v1). */ function interact(): void { + if (bagOpen) return; // «E» под сумкой не действует (мир замер) if (runner.active) { if (!runner.waitingForChoice) runner.advance(); updateHud(); @@ -591,6 +613,45 @@ inv.onChange(updateHud); updateHud(); +// ---- Панель сумки (клавиша I): предметы с названиями, мир стоит ---- +const bagEl = document.getElementById('bag') as HTMLDivElement; +let bagOpen = false; + +/** Показ/скрытие панели сумки (содержимое — на момент открытия). */ +function renderBag(): void { + if (!bagOpen) { + bagEl.hidden = true; + return; + } + bagEl.replaceChildren(); + const title = document.createElement('div'); + title.className = 'title'; + title.textContent = 'СУМКА'; + bagEl.append(title); + if (inv.empty) { + const empty = document.createElement('div'); + empty.textContent = 'Пусто.'; + bagEl.append(empty); + } + for (const slot of inv.all) { + const row = document.createElement('div'); + row.className = 'item'; + row.textContent = `${itemName(slot.id)} — ${slot.count}`; + bagEl.append(row); + } + const foot = document.createElement('div'); + foot.className = 'foot'; + foot.textContent = 'I — закрыть'; + bagEl.append(foot); + bagEl.hidden = false; +} + +// ---- Дев-панель (F3): список объектов + вьювер движка ---- +const dev = new DevPanel((open) => { + if (open) showHint(''); + updateHud(); +}); + /** Тост: всплывашка эффекта, уходит сама (реальное время). */ function showToast(text: string): void { const el = document.createElement('div'); @@ -690,6 +751,64 @@ extras: { area: currentArea.id }, }); menuContinue.disabled = !saves.has('auto'); + renderSlots(); +} + +// ---- Слоты сейвов: auto + 3 ручных, сохранить/загрузить/удалить ---- +const slotsEl = document.getElementById('slots') as HTMLDivElement; +const MANUAL_SLOTS = ['s1', 's2', 's3']; +const SLOT_LABELS: Record = { auto: 'Автосейв', s1: 'Слот 1', s2: 'Слот 2', s3: 'Слот 3' }; + +/** Строка времени сейва (локаль браузера; нет меты — пусто). */ +function slotWhen(meta: { savedAt?: number; title?: string } | null): string { + if (!meta?.savedAt) return ''; + const when = new Date(meta.savedAt).toLocaleString('ru-RU'); + return meta.title ? `${meta.title} · ${when}` : when; +} + +/** Кнопка строки слота (disabled без действия). */ +function slotButton(text: string, on: () => void, enabled: boolean): HTMLButtonElement { + const btn = document.createElement('button'); + btn.textContent = text; + btn.disabled = !enabled; + btn.addEventListener('click', () => { on(); }); + return btn; +} + +/** Перерисовать список слотов (открытие меню и после сохранения/удаления). */ +function renderSlots(): void { + slotsEl.innerHTML = '

СОХРАНЕНИЯ

'; + for (const slot of ['auto', ...MANUAL_SLOTS]) { + const row = document.createElement('div'); + row.className = 'slot'; + const name = document.createElement('span'); + name.className = 'name'; + const meta = saves.slotMeta(slot); + name.textContent = SLOT_LABELS[slot]; + if (meta) { + const when = document.createElement('span'); + when.className = 'when'; + when.textContent = slotWhen(meta); + name.appendChild(when); + } + const save = () => { + saves.save(slot, saveSnapshot(), { + title: currentArea.id, savedAt: Date.now(), version: 1, extras: { area: currentArea.id }, + }); + renderSlots(); + }; + const load = () => { + const s = saves.load(slot); + if (s) { applySave(s); closeMenu(); } + }; + const del = () => { saves.delete(slot); renderSlots(); }; + const has = saves.has(slot); + row.appendChild(name); + if (slot !== 'auto') row.appendChild(slotButton('Сохранить', save, true)); + row.appendChild(slotButton('Загрузить', load, has)); + row.appendChild(slotButton('Удалить', del, has)); + slotsEl.appendChild(row); + } } /** Закрыть меню без загрузки (Esc в меню; «Войти в мир» — мир уже стоит). */ @@ -724,6 +843,7 @@ // Esc: меню-пауза с автосейвом; открытый диалог прерывается (сейвы без диалогов) if (e.code === 'Escape') { e.preventDefault(); + if (bagOpen) { bagOpen = false; renderBag(); return; } // сумка закрывается первой if (menuOpen) closeMenu(); else { if (runner.active) runner.abort(); @@ -740,6 +860,19 @@ e.preventDefault(); interact(); } + // I: панель сумки (мир стоит, Esc/меню закрывают) + if (e.code === 'KeyI') { + e.preventDefault(); + bagOpen = !bagOpen; + if (bagOpen && menuOpen) closeMenu(); + renderBag(); + } + // F3 / «: дев-панель (список объектов и вьювер моделей) + if (e.code === 'F3' || e.code === 'Backquote') { + e.preventDefault(); + if (dev.open) dev.close(); + else dev.openPanel(); + } // выбор варианта ответа — цифрами (позиция в показанном списке) if (runner.active && runner.waitingForChoice && /^Digit[1-9]$/.test(e.code)) { e.preventDefault(); @@ -773,6 +906,7 @@ /** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */ function tick(dt: number): void { if (menuOpen) return; // меню-пауза: мир стоит (автосейв снимается при открытии) + if (dev.open) return; // дев-панель/вьювер: мир стоит (просмотр моделей) // время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1) clock.advance(dt); elapsed += dt; @@ -809,7 +943,11 @@ showHint(''); return; } - const talking = runner.active; + const talking = runner.active; // мир стоит и при открытой сумке + if (bagOpen) { + showHint(''); + return; + } // в диалоге герой стоит (как в v1: мир не тикается под репликой) const move: readonly [number, number] = talking ? [0, 0] : (agentInput ?? keyboardMove()); const wm = screenMoveToWorld(move, ISO_YAW); @@ -914,11 +1052,13 @@ } // ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ---- +const loadingEl = document.getElementById('loading') as HTMLDivElement; const loop = new GameLoop(60, { update: (dt) => tick(dt), render: () => { remeshDirty(); renderer.render(); + if (!loadingEl.hidden) loadingEl.hidden = true; // лоад-скрин: до первого кадра }, }); setArea(currentArea.id, currentArea.spawn); // старт — спавн лугов @@ -1174,9 +1314,69 @@ stateInfo() { return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) }; }, - /** Снапшот меню: открыто ли, есть ли автосейв (гейт меню/сейвов). */ + /** Панель сумки: открыта ли, содержимое с названиями (гейт I). */ + bagInfo() { + return { + open: bagOpen, + items: inv.all.map((s) => ({ id: s.id, name: itemName(s.id), count: s.count })), + }; + }, + /** Панель сумки агентом (клавиша I). */ + toggleBag(): void { + bagOpen = !bagOpen; + if (bagOpen && menuOpen) closeMenu(); + renderBag(); + }, + /** Дев-панель: каталог объектов (гейт). */ + devInfo() { + return { open: dev.open, entries: devEntries() }; + }, + /** Дев-панель агентом: открыть список / вьювер / закрыть. */ + devOpen(): void { + if (!dev.open) dev.openPanel(); + }, + devOpenEntry(id: string): boolean { + if (!dev.open) dev.openPanel(); + dev.openViewer(id); + return !document.getElementById('devview')!.hidden; + }, + devClose(): void { + dev.close(); + }, + /** Статы вьювера (числовой снапшот: воксели/кости/тайли). */ + devStats() { + return dev.stats(); + }, + /** Снапшот меню: открыто ли, есть ли автосейв, строки слотов (гейт). */ menuInfo() { - return { open: menuOpen, hasAuto: saves.has('auto') }; + return { + open: menuOpen, + hasAuto: saves.has('auto'), + slots: ['auto', ...MANUAL_SLOTS].map((slot) => ({ + slot, + has: saves.has(slot), + meta: saves.slotMeta(slot), + })), + }; + }, + /** Слоты: сохранить/загрузить/удалить агентом (как кнопки меню). */ + saveTo(slot: string): void { + saves.save(slot, saveSnapshot(), { + title: currentArea.id, savedAt: Date.now(), version: 1, extras: { area: currentArea.id }, + }); + if (menuOpen) renderSlots(); + }, + loadFrom(slot: string): boolean { + const s = saves.load(slot); + if (!s) return false; + applySave(s); + if (menuOpen) closeMenu(); + updateHud(); + return true; + }, + saveDelete(slot: string): void { + saves.delete(slot); + if (menuOpen) renderSlots(); }, /** Меню: открытие с автосейвом / закрытие / вход в игру (гейт). */ gotoMenu(): void { diff --git a/v2/apps/game/tools/checks/panels.mjs b/v2/apps/game/tools/checks/panels.mjs new file mode 100644 index 0000000..78b1a82 --- /dev/null +++ b/v2/apps/game/tools/checks/panels.mjs @@ -0,0 +1,141 @@ +/** + * Гейт панелей UI (трек 28): лоад-скрин скрыт после первого кадра, слоты + * сейвов в меню (auto + 3 ручных: сохранить/загрузить/удалить), панель + * сумки по I (мир стоит), дев-панель F3 + вьювер моделей движка (статы, + * скины, закрытие). Скриншоты: /tmp/v2_panels_*.png. + * + * Запуск: node apps/game/tools/checks/panels.mjs [--port 5326] + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5326); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'panels', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Скриншот → RGBA-буфер. */ +async function shot(client, path) { + await client.screenshot(path); + return decodePng(readFileSync(path)).rgba; +} + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + check('лоад-скрин скрыт после первого кадра', + await client.evaluate(() => document.getElementById('loading').hidden) === true); + + await client.call('startGame', false); // страница стартует в меню + await client.call('setManual', true); + await client.call('stepTicks', 10); + + // ---- Слоты сейвов ---- + await client.call('gotoMenu'); + await client.call('stepTicks', 1); + const menu1 = await client.call('menuInfo'); + check('меню открыто, 4 слота (auto + 3 ручных)', + menu1.open === true && menu1.slots.length === 4 && + menu1.slots.map((s) => s.slot).join(',') === 'auto,s1,s2,s3', menu1.slots.map((s) => s.slot)); + check('автосейв в меню записан (has, мета с областью)', + menu1.hasAuto === true && + menu1.slots[0].meta && typeof menu1.slots[0].meta.savedAt === 'number', menu1.slots[0].meta); + + // ломаем состояние (уезд героя), сохраняем в ручной слот, загружаем + await client.call('startGame', false); + await client.call('heroTeleport', 205, 145); // далеко от спавна + await client.call('stepTicks', 2); + await client.call('saveTo', 's1'); + const saved = await client.call('menuInfo'); + check('saveTo записал слот s1 с метой', + saved.slots[1].has === true && saved.slots[1].meta.title === 'meadows', saved.slots[1].meta); + + await client.call('heroTeleport', 95, 125); + await client.call('stepTicks', 2); + check('loadFrom вернул героя в слот-позицию', + await client.call('loadFrom', 's1') === true); + await client.call('stepTicks', 2); + const back = await client.call('heroInfo'); + check('позиция из сейва восстановлена', + Math.abs(back.pos[0] - 205) < 1 && Math.abs(back.pos[2] - 145) < 1, back.pos); + + await client.call('saveDelete', 's1'); + check('saveDelete чистит слот', + (await client.call('menuInfo')).slots[1].has === false); + check('loadFrom пустого слота — false', + await client.call('loadFrom', 's1') === false); + + // ---- Панель сумки (I) ---- + await client.call('toggleBag'); + const bag1 = await client.call('bagInfo'); + check('панель сумки открыта (мост + DOM)', + bag1.open === true && + await client.evaluate(() => !document.getElementById('bag').hidden)); + const pos1 = (await client.call('heroInfo')).pos; + await client.call('stepTicks', 30); + const pos2 = (await client.call('heroInfo')).pos; + check('мир стоит при открытой сумке', + pos1[0] === pos2[0] && pos1[2] === pos2[2], { pos1, pos2 }); + check('сумка рисует содержимое (пустая — «Пусто.»)', + await client.evaluate(() => document.getElementById('bag').textContent.includes('Пусто.')) === true); + await client.call('toggleBag'); + check('панель сумки закрылась', + (await client.call('bagInfo')).open === false); + + // ---- Дев-панель + вьювер ---- + const dev1 = await client.call('devInfo'); + check('каталог объектов: персонажи + природа + строения', + dev1.entries.length >= 14 && + dev1.entries.some((e) => e.id.startsWith('char:')) && + dev1.entries.some((e) => e.id === 'deer') && + dev1.entries.some((e) => e.id === 'machine'), dev1.entries.length); + + await client.call('devOpen'); + check('панель списка открыта', + await client.evaluate(() => !document.getElementById('dev').hidden) === true); + + const heroId = dev1.entries.find((e) => e.id.startsWith('char:')).id; + check('вьювер открылся', await client.call('devOpenEntry', heroId) === true); + const st1 = await client.call('devStats'); + check('статы манекена: 164 вокс / 5 костей / 16 тайлей', + st1.voxels === 164 && st1.bones === 5 && st1.tiles === 16, st1); + + // кадр вьювера рисует модель (зона канваса не пустая) + await client.call('stepTicks', 1); + const viewPng = await shot(client, '/tmp/v2_panels_view.png'); + let lit = 0; + for (let i = 0; i < viewPng.length; i += 4) if (viewPng[i] + viewPng[i + 1] + viewPng[i + 2] > 45) lit++; + check('вьювер рисует модель (не пустой кадр)', lit > 500, lit); + + check('вьювер закрылся, мир снова тикается', + await client.call('devClose') === undefined && + await client.evaluate(() => document.getElementById('devview').hidden) === true); + check('каталог открыт повторно', await client.call('devOpenEntry', 'deer') === true); + const st2 = await client.call('devStats'); + check('статы оленя: без риги, есть воксели', + st2.bones === 0 && st2.voxels > 50, st2); + await client.call('devClose'); + await client.call('devClose'); // панель тоже + + const errs = client.errors(); + check('нет pageerror за прогон', errs.length === 0, errs.slice(0, 3)); + } finally { + await browser.close(); + } +} catch (e) { + report.error = String(e && e.stack ? e.stack : e); + console.error(report.error); +} finally { + server.stop(); +} +report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok); +console.log(JSON.stringify(report)); \ No newline at end of file diff --git a/v2/apps/game/tools/probe-dev.mjs b/v2/apps/game/tools/probe-dev.mjs new file mode 100644 index 0000000..0200f75 --- /dev/null +++ b/v2/apps/game/tools/probe-dev.mjs @@ -0,0 +1,39 @@ +// Одноразовая проба трека 28: дев-панель + вьювер моделей +import { startDevServer, launchBrowser, openGame } from '@rpg/engine/tools/agent-lib.mjs'; +const dev = startDevServer({ port: 5334, cwd: 'apps/game' }); +await dev.waitReady; +const browser = await launchBrowser(); +try { + const client = await openGame(browser, { url: 'http://localhost:5334/' }); + await client.call('startGame', false); + await client.call('stepTicks', 20); + console.log('каталог:', (await client.call('devInfo')).entries.map((e) => e.id).join(', ')); + await client.call('devOpen'); + console.log('панель открыта:', (await client.call('devInfo')).open); + const id = 'char:Путник (герой)'; + console.log('вьювер:', await client.call('devOpenEntry', id)); + await client.screenshot('/tmp/t28_dev_hero.png'); + console.log('статы:', await client.call('devStats')); + // скин: переключить на Милу + await client.evaluate(() => { + const sel = document.getElementById('dev-skin'); + sel.value = '1'; + sel.dispatchEvent(new Event('change')); + }); + await client.screenshot('/tmp/t28_dev_mila.png'); + console.log('статы после скина:', await client.call('devStats')); + // олень и дерево + console.log('олень:', await client.call('devOpenEntry', 'deer')); + await client.screenshot('/tmp/t28_dev_deer.png'); + console.log('статы оленя:', await client.call('devStats')); + console.log('дерево:', await client.call('devOpenEntry', 'tree0')); + await client.screenshot('/tmp/t28_dev_tree.png'); + console.log('статы дерева:', await client.call('devStats')); + await client.call('devClose'); + console.log('закрыто:', !(await client.call('devInfo')).open); + console.log('pageerrors:', client.errors()); +} finally { + await browser.close(); + dev.stop(); + process.exit(0); +} diff --git a/v2/apps/game/tools/probe-house.mjs b/v2/apps/game/tools/probe-house.mjs new file mode 100644 index 0000000..296befc --- /dev/null +++ b/v2/apps/game/tools/probe-house.mjs @@ -0,0 +1,57 @@ +/** + * Одноразовая проба (трек 28): зайти в дом Ирвина и выйти — герой не должен + * застревать в стенах дома (дома штампуются шире тайла). Печатает позицию + * героя после возврата и solidAt вокруг неё. + */ +import { fileURLToPath } from 'node:url'; +import { AgentClient, launchBrowser, startDevServer, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5332); +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../', import.meta.url)) }); +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const page = await browser.newPage(); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(String(e))); + await page.goto(`http://localhost:${port}/`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction('window.__beacon === true', { timeout: 60000 }); + await new Promise((r) => setTimeout(r, 1200)); + const client = new AgentClient(page); + client.pageErrors = pageErrors; + await client.call('startGame', false); + await client.call('setManual', true); + // к двери дома Ирвина (13,12): телепорт на тайл югом, шаг к двери, E + await client.call('gotoArea', 'zvenets', { x: 13, y: 13 }); + await client.call('stepTicks', 2); + await client.call('interact'); // клик-переход у двери + await client.call('stepTicks', 40); // полный фейд + const inside = await client.call('heroInfo'); + console.log('внутри:', inside.area, inside.tile); + // к выходу: телепорт на тайл СЕВЕРНЕЕ спавна (5,6), шаг +z через + // спавн (5,7) в триггер выхода (5,8); +z = экранный стик (-1,1) + await client.call('heroTeleport', 5 * 10 + 5, 6 * 10 + 5); + await client.call('stepTicks', 2); + await client.call('setInput', -1, 1); + for (let i = 0; i < 20; i++) { + await client.call('stepTicks', 6); // держим стик, пока не выйдет + const info = await client.call('heroInfo'); + if (info.area !== 'house_elder') break; + } + await client.call('setInput', null, null); + await client.call('stepTicks', 40); // фейд возврата + const out = await client.call('heroInfo'); + console.log('после выхода:', out.area, out.tile, 'pos:', out.pos); + for (const [dx, dz] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) + console.log('solid', dx, dz, await client.call('solidAt', Math.round(out.pos[0]) + dx * 10, Math.round(out.pos[2]) + dz * 10)); + await client.screenshot('/tmp/t28_house_out.png'); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + process.exit(1); +} +process.exit(0); diff --git a/v2/apps/game/tools/probe-load.mjs b/v2/apps/game/tools/probe-load.mjs new file mode 100644 index 0000000..e82574e --- /dev/null +++ b/v2/apps/game/tools/probe-load.mjs @@ -0,0 +1,17 @@ +// Одноразовая проба трека 28: лоад-скрин скрыт после первого кадра +import { startDevServer, launchBrowser, openGame } from '@rpg/engine/tools/agent-lib.mjs'; +const dev = startDevServer({ port: 5333, cwd: 'apps/game' }); +await dev.waitReady; +const browser = await launchBrowser(); +try { + const client = await openGame(browser, { url: `http://localhost:5333/` }); + console.log('loading hidden после загрузки:', await client.evaluate(() => document.getElementById('loading').hidden)); + await client.call('startGame', false); + await client.call('stepTicks', 30); + console.log('после startGame: hidden =', await client.evaluate(() => document.getElementById('loading').hidden)); + await client.screenshot('/tmp/t28_load.png'); +} finally { + await browser.close(); + dev.stop(); + process.exit(0); +} diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index db34033..e12d509 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -23,6 +23,9 @@ export { meshChunk, chunkBounds } from './world/worldMesher'; export { Chunk, CHUNK_SX, CHUNK_SZ } from './world/chunk'; export { VoxelRenderer } from './render/voxelRenderer'; +export type { CloudTiles } from './render/voxelRenderer'; +export { ModelViewer, modelStats, orbitPosition } from './render/modelViewer'; +export type { ModelStats, ModelViewerOptions } from './render/modelViewer'; export type { VoxelRendererOptions, VoxelCloud } from './render/voxelRenderer'; export { Rng } from './math/rng'; export { encodeModel, decodeModel, validateModel, assertModelValid, texIdAt, texBytes, resolveTiles } from './models/format'; diff --git a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts new file mode 100644 index 0000000..ff1b98f --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { orbitPosition, modelStats } from '../modelViewer'; +import { mannequin } from '../../models/mannequin'; + +describe('ModelViewer — чистые части (урок №3)', () => { + it('орбита: yaw 0 — камера по +x, pitch 0 — на горизонтали', () => { + expect(orbitPosition(0, 0, 10, [0, 4, 0])).toEqual([10, 4, 0]); + // pitch π/2 — строго над целью + const top = orbitPosition(0.7, Math.PI / 2, 12, [1, 2, 3]); + expect(top[0]).toBeCloseTo(1, 6); + expect(top[1]).toBeCloseTo(14, 6); + expect(top[2]).toBeCloseTo(3, 6); + // yaw π/2 — по +z; дистанция масштабирует горизонтальные составляющие + const p = orbitPosition(Math.PI / 2, 0, 5, [0, 0, 0]); + expect(p[0]).toBeCloseTo(0, 6); + expect(p[2]).toBeCloseTo(5, 6); + }); + + it('статы манекена: воксели/кости/тайли считаются по сетке', () => { + const { model, rig } = mannequin(); + const s = modelStats(model, rig); + expect(s.size).toEqual([10, 16, 4]); + expect(s.voxels).toBe(164); + expect(s.bones).toBe(5); + expect(s.tiles).toBe(16); + // без риги — 0 костей + expect(modelStats(model).bones).toBe(0); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/modelViewer.ts b/v2/packages/engine/src/render/modelViewer.ts new file mode 100644 index 0000000..f938411 --- /dev/null +++ b/v2/packages/engine/src/render/modelViewer.ts @@ -0,0 +1,221 @@ +/** + * ModelViewer — просмотрщик воксельных моделей для дев-панели (трек 28). + * Орбитальная камера поверх VoxelRenderer (тот же текстурный слой/тайли), + * воспроизведение клипов риги, авто-поворот (turntable), статы модели. + * Игровая дев-панель — надстройка игры: движок даёт класс, панель — UI. + * Чистая математика орбиты и статов — отдельные функции (урок №3). + */ +import type { VoxelModel } from '../models/format'; +import { decodeModel } from '../models/format'; +import type { VoxelRig, Pose } from '../animation/rig'; +import { poseVoxels } from '../animation/rig'; +import type { AnimClip } from '../animation/clip'; +import { samplePose } from '../animation/clip'; +import type { Vec3 } from '../animation/mat'; +import { VoxelRenderer, type CloudTiles } from './voxelRenderer'; +import type { VoxelCloud } from './voxelRenderer'; + +/** Статы модели для панели (числовой снапшот агентом). */ +export interface ModelStats { + /** Размер сетки [sx, sy, sz]. */ + size: [number, number, number]; + /** Занятых вокселей. */ + voxels: number; + /** Костей риги (0 — модель без риги). */ + bones: number; + /** Число тайлей текстурного слоя (0 — без слоя tex). */ + tiles: number; +} + +/** Сферическая орбита камеры: yaw/pitch вокруг target на дистанции. */ +export function orbitPosition( + yaw: number, pitch: number, dist: number, target: readonly [number, number, number], +): Vec3 { + const cp = Math.cos(pitch); + return [ + target[0] + dist * cp * Math.cos(yaw), + target[1] + dist * Math.sin(pitch), + target[2] + dist * cp * Math.sin(yaw), + ]; +} + +/** Числовые статы модели (занятые воксели — по декодированной сетке). */ +export function modelStats(model: VoxelModel, rig?: VoxelRig): ModelStats { + const grid = decodeModel(model); + let voxels = 0; + for (let i = 0; i < grid.data.length; i++) if (grid.data[i] !== 0) voxels++; + return { + size: [...model.size] as [number, number, number], + voxels, + bones: rig?.bones.length ?? 0, + tiles: model.tiles ? Object.keys(model.tiles).length : 0, + }; +} + +export interface ModelViewerOptions { + /** размер бэкинг-стора (по умолчанию 480×360) */ + width?: number; + height?: number; + /** половина высоты орто-фрустума (зум), в воксельных единицах */ + viewSize?: number; + /** скорость авто-поворота (рад/с); 0 — без вращения */ + spin?: number; +} + +/** Просмотрщик: модель + клип риги, орбита мышью, статы. */ +export class ModelViewer { + readonly renderer: VoxelRenderer; + private readonly canvas: HTMLCanvasElement; + private readonly viewSize: number; + private spinSpeed: number; + private cloud: VoxelCloud | null = null; + private model: VoxelModel | null = null; + private rig: VoxelRig | null = null; + private clip: AnimClip | null = null; + private tiles: CloudTiles | null = null; + private time = 0; + private playing = true; + private spin = 0; // накопленный авто-поворот (рад) + // орбита: yaw/pitch — мышью или методами (гейт), zoom — множитель viewSize + private yaw = Math.PI / 4; + private pitch = 0.5; + private zoom = 1; + private drag: { x: number; y: number } | null = null; + private disposed = false; + + constructor(canvas: HTMLCanvasElement, opts: ModelViewerOptions = {}) { + this.canvas = canvas; + const width = opts.width ?? 480; + const height = opts.height ?? 360; + this.viewSize = opts.viewSize ?? 16; + this.spinSpeed = opts.spin ?? 0; + this.renderer = new VoxelRenderer(canvas, { width, height, viewSize: this.viewSize }); + canvas.addEventListener('pointerdown', this.onDown); + canvas.addEventListener('pointermove', this.onMove); + canvas.addEventListener('pointerup', this.onUp); + canvas.addEventListener('wheel', this.onWheel, { passive: false }); + this.placeCamera(); + } + + /** + * Показать модель. rig+clip — анимация (поза семплится в render); + * tiles — текстурный слой (`resolveTiles(model)`), палитра — в модели. + */ + setModel(model: VoxelModel, opts: { rig?: VoxelRig; clip?: AnimClip; tiles?: CloudTiles } = {}): void { + this.model = model; + this.rig = opts.rig ?? null; + this.clip = opts.clip ?? null; + this.tiles = opts.tiles ?? null; + this.time = 0; + this.cloud?.dispose(); + const pose: Pose = {}; + this.cloud = this.renderer.addVoxelCloud(poseVoxels(this.rig ?? dummyRig(model), model, pose), this.tiles ?? undefined); + this.refreshCloud(); + this.placeCamera(); + } + + /** Пауза/продолжение клипа. */ + play(on: boolean): void { + this.playing = on; + } + + /** Авто-поворот (turntable), рад/с; 0 — выключить. */ + setSpin(radPerSec: number): void { + this.spinSpeed = radPerSec; + } + + /** Прыжок клипа в момент t (сек). */ + setTime(t: number): void { + this.time = t; + this.refreshCloud(); + } + + /** Орбита программой (гейтом): угол поворота и возвышения (рад). */ + setOrbit(yaw: number, pitch: number): void { + this.yaw = yaw; + this.pitch = Math.min(Math.PI / 2 - 0.05, Math.max(0.05, pitch)); + this.placeCamera(); + } + + /** Зум: доля от базового viewSize (0.3..3). */ + setZoom(k: number): void { + this.zoom = Math.min(3, Math.max(0.3, k)); + this.placeCamera(); + } + + /** Один кадр: авто-поворот, семпл позы клипа, рендер. */ + render(dt: number): void { + if (this.disposed) return; + if (this.spinSpeed) { + this.spin += this.spinSpeed * dt; + this.placeCamera(); + } + if (this.clip) { + if (this.playing) this.time = (this.time + dt) % this.clip.duration; + this.refreshCloud(); + } + this.renderer.render(); + } + + /** Статы текущей модели (панель/мост). */ + stats(): ModelStats | null { + return this.model ? modelStats(this.model, this.rig ?? undefined) : null; + } + + dispose(): void { + this.disposed = true; + this.cloud?.dispose(); + this.canvas.removeEventListener('pointerdown', this.onDown); + this.canvas.removeEventListener('pointermove', this.onMove); + this.canvas.removeEventListener('pointerup', this.onUp); + this.canvas.removeEventListener('wheel', this.onWheel); + } + + /** Пересемплить позу клипа в облако (облако есть — модель стоит). */ + private refreshCloud(): void { + if (!this.cloud || !this.model) return; + const pose = this.clip ? samplePose(this.clip, this.time) : {}; + this.cloud.update(poseVoxels(this.rig ?? dummyRig(this.model), this.model, pose)); + } + + /** Камера на сферической орбите вокруг центра модели. */ + 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 pos = orbitPosition(this.yaw + this.spin, this.pitch, dist, target); + const cam = this.renderer.camera; + cam.position.set(...pos); + cam.lookAt(target[0], target[1], target[2]); + cam.updateMatrixWorld(); + } + + private onDown = (e: PointerEvent): void => { + this.drag = { x: e.clientX, y: e.clientY }; + this.canvas.setPointerCapture(e.pointerId); + }; + + private onMove = (e: PointerEvent): void => { + if (!this.drag) return; + this.yaw -= (e.clientX - this.drag.x) * 0.01; + this.pitch = Math.min(Math.PI / 2 - 0.05, Math.max(0.05, this.pitch + (e.clientY - this.drag.y) * 0.01)); + this.drag = { x: e.clientX, y: e.clientY }; + this.placeCamera(); + }; + + private onUp = (): void => { + this.drag = null; + }; + + private onWheel = (e: WheelEvent): void => { + e.preventDefault(); + this.setZoom(this.zoom * (e.deltaY > 0 ? 1.1 : 0.9)); + }; +} + +/** Рига-пустышка: модели без риги — один root, все воксели к нему. */ +function dummyRig(model: VoxelModel): VoxelRig { + const count = model.size[0] * model.size[1] * model.size[2]; + return { bones: [{ name: 'root', parent: -1, pivot: [0, 0, 0] }], binding: new Array(count).fill(0) }; +} \ No newline at end of file