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