diff --git a/docs/plan.md b/docs/plan.md index acf05a3..0361b0b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -775,6 +775,24 @@ игры зелёные, 295 тестов. Грабля: центр тайла при нечётном UNIT — `tile*UNIT + 2`, не 1.5; рельсы — нить d=0..UNIT-1 (была с дырками); spotVoxel тайла — tile*5+2. +- **Трек 23 — масштаб UNIT 5→10 + детализация героя + частицы из мира** + (ЗАКРЫТ 2026-09-10, фидбек после прогона: «частицы летают вместе с + персонажем, как приклеенные»; «персонажи низкодетализированные, не + читаются; объекты всё ещё сильно меньше»): 1 тайл v1 = 10×10 вокселей + (герой 22 ≈ 2.2 тайла, дом ~4 тайла — вдвое выше героя, дерево ~3.6). + Частицы (пепел, мотыльки) отвязаны от камеры: мировой якорь + дрейф, + окно камеры заворачивает (`wrap`) — при движении героя стоят на месте. + Генераторы под тайл 10×10 (ствол 2×2, дерево ~30–38, дом 24×30–40×18, + башня 47, Машина 24×34×18), высота мира 52, стены 13. **Манекен движка + переписан: 5×11×3 → 11×21×4** (голова-куб, торс, руки/ноги 2×2 — силуэт + читается; 220 вокселей; тест связности — BFS по 6-связности вместо + лесенки-сорта). Герой 2.1 юнита/с (stride 12.8 — темп v1), камера + viewSize 80, радиусы интеракта 15. Гейты переведены (`t*10+5`, ±10, + диапазон камеры tune 125..255): 6 игры + 11 демок зелёные, 295 тестов. + Грабли: UNIT=10 чётный — центр тайла `tile*UNIT + 5`; ствол дерева 2×2 + (1×1 на этом масштабе — щепка); в проверках гейтов легко оставить старый + `+5` (сравнение возврата камеры) — искать grep-ом `+ 5)` после смены + масштаба. - **Раунд полировки** (2026-09-10, «улучшим игру технически и визуально»): недоделки визуального слоя трека 18 + техполировка. Меню (Esc) + автосейв/«Продолжить» (движковый SaveManager, localStorage `rpg2:`; diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index eda3cdd..dc03da2 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -92,7 +92,7 @@ - `src/models/paletteMap.ts` — `remapSlots(model, palette)`: перекладка слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox; - - `src/models/mannequin.ts` — `mannequin()`: эталонная модель 5×11×3 + - `src/models/mannequin.ts` — `mannequin()`: эталонная модель 11×21×4 с ригой (root → legL/legR/armL/armR, pivot в связочном пространстве); - `src/animation/mat.ts` — аффинная математика без three (Vec3/Mat3/Affine, порядок Эйлера X→Y→Z, rotateAround); чистая, тестируется без рендера; @@ -769,6 +769,31 @@ диапазон камеры катсцены tune пересчитан (цель — тон-дерево [12,10]). Радиусы света в данных — в юнитах, масштабируются `×UNIT` в движке. +## Трек 23 (2026-09-10): масштаб UNIT 5→10 + детализация героя + частицы из мира + +Фидбек после прогона: «частицы летают вместе с персонажем, как приклеенные»; +«персонажи низкодетализированные, не читаются; объекты всё ещё сильно меньше — +масштаб поднимать кардинально». Выбран UNIT 5→10 (AskUserQuestion). +- Частицы (`ash.ts`, `ambientParticles.ts`): поза считалась прямо от target + камеры — хлопья/мотыльки ехали вместе с героем. Теперь мировой якорь + (`i*61.7` / `i*53.1` и т.п.) + дрейф, а окно камеры заворачивает их + (`wrap(v, min, size)`) — ушедший за край появляется с другой стороны; + при движении героя частицы стоят на месте в мире. +- `areas.ts`: UNIT=10 (чётный — центр тайла `tile*UNIT + 5`), генераторы + под тайл 10×10 (ствол дерева 2×2 — 1×1 на этом масштабе щепка; дерево + ~30–38, дом 24×30–40×18, башня 47, Машина 24×34×18), высота мира 52, + стены интерьера 13. Цветок/декор — на +5, рельсы — 2 вокселя шириной. +- Манекен движка (`packages/engine/src/models/mannequin.ts`): 5×11×3 → + **11×21×4** (голова-куб 3×2×5, торс 5×2×7, руки/ноги 2×2, просвет между + ногами; 220 вокселей, 5 костей, явная привязка). Тест связности частей — + BFS по 6-связности (лесенка-сорт не проходит 2×2-колонны). +- Герой: BODY_SIZE [5,21,5], speed 21, stride 12.8 (темп v1 сохранён), + bob 0.5/sway 0.3; камера viewSize 80 (охват 8 тайлов); NPC_RADIUS/ + INTERACT_RANGE 15, PICK_EPS 0.5. Олень подрос (~13 вокс). +- Гейты: `t*10+5`, подходы ±10, диапазон камеры tune 125..255, проверка + возврата камеры `rez.x + 10`. Все 6 гейтов игры + 11 демок зелёные, + 295 тестов. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/ambientParticles.ts b/v2/apps/game/src/ambientParticles.ts index 1cb1b1c..28d2fb0 100644 --- a/v2/apps/game/src/ambientParticles.ts +++ b/v2/apps/game/src/ambientParticles.ts @@ -14,7 +14,7 @@ const MOTH_COLORS = ['#f0d878', '#d8b050']; // B-тёплые: только «жизнь» const FOG_COUNT = 22; const FOG_COLOR = '#a7b3bd'; // светлее воды — клочья читаются туманом -const RADIUS = 30; // половина ширины облака мотыльков вокруг камеры (как пепел) +const RADIUS = 24; // половина окна мотыльков вокруг камеры (мировые якоря) /** Клок тумана: квадрат 2×2 вокселя ЛЕЖИТ на воде (как полупрозрачные * пятна v1 — непрозрачный куб в воздухе читался бы «ящиком»). */ const FOG_TILE: readonly (readonly [number, number])[] = [[0, 0], [1, 0], [0, 1], [1, 1]]; @@ -22,6 +22,13 @@ * граней на кромках (как у бликов шиммера, EPS 0.03). */ const FOG_EPS = 0.03; +/** Заворачивание значения в окно [min, min+size) — как у пепла. */ +const wrap = (v: number, min: number, size: number): number => { + let r = (v - min) % size; + if (r < 0) r += size; + return min + r; +}; + export class AmbientParticles { private readonly cloud: VoxelCloud; @@ -49,13 +56,14 @@ if (this.kind === 'moths') { for (let i = 0; i < MOTH_COUNT; i++) { const p = i * 2.399; // золотое сечение: разброс фаз - // кружение вокруг камеры: медленный орбитальный дрейф - const x = target[0] + Math.sin(t * 0.13 + p) * RADIUS * 0.8; - const z = target[2] + Math.cos(t * 0.11 + p * 1.7) * RADIUS * 0.8; - // полёт: плавная волна над травой + трепет - const y = 4.5 + Math.sin(t * 0.4 + p * 2) * 2.2; + // мировой якорь (окно камеры заворачивает) + кружение НА месте: + // мотылёк не следует за героем — герой летит сквозь стаю + const ax = wrap(i * 53.1 + Math.sin(t * 0.13 + p) * 4, target[0] - RADIUS, RADIUS * 2); + const az = wrap(i * 89.7 + Math.cos(t * 0.11 + p * 1.7) * 4, target[2] - RADIUS, RADIUS * 2); + // полёт: плавная волна над травой + трепет (герой ~22 вокс) + const y = 9 + Math.sin(t * 0.4 + p * 2) * 4.4; voxels.push({ - pos: [x + Math.sin(t * 1.7 + p) * 0.6, y, z + Math.cos(t * 1.5 + p) * 0.6], + pos: [ax + Math.sin(t * 1.7 + p) * 0.6, y, az + Math.cos(t * 1.5 + p) * 0.6], color: MOTH_COLORS[i % MOTH_COLORS.length]!, }); } diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index 798c1c0..f7bd3eb 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -1,11 +1,11 @@ /** * Области «Пепельных луга» на v2: раскладка тайлов v1 (maps.json, референс) - * → воксельный мир. 1 тайл v1 = 5×5 вокселей (1 юнит = 5 вокселей; герой 11 - * ≈ 2 тайла ростом — дерево/дом заметно выше, пропорции «человек и мир»). - * Рельеф — плоский (земля y0-1), объекты — штампы генераторов движка - * (деревья/валуны) и локальных генераторов контента (кусты, дома, колодец, - * башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются на - * границе: центр тайла = (tile*UNIT + 2) в воксельных координатах. + * → воксельный мир. 1 тайл v1 = 10×10 вокселей (1 юнит = 10 вокселей; герой + * 22 ≈ 2.2 тайла ростом — дом ~4 тайла, дерево ~3.6, пропорции «человек + * и мир»). Рельеф — плоский (земля y0-1), объекты — штампы генераторов + * движка (деревья/валуны) и локальных генераторов контента (кусты, дома, + * колодец, башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются + * на границе: центр тайла = (tile*UNIT + 5) в воксельных координатах. */ import { VoxelWorld, VoxelGrid, stampModel, @@ -28,8 +28,8 @@ export const AREA_LAYOUTS = mapsData as unknown as Record; -/** Вокселей на тайл v1 (1 юнит = 5 вокселей — герой 11 ≈ 2 тайла ростом). */ -export const UNIT = 5; +/** Вокселей на тайл v1 (1 юнит = 10 вокселей — герой 22 ≈ 2.2 тайла ростом). */ +export const UNIT = 10; /** Точка ног героя в вокселях (верх земли). */ export const GROUND_Y = 2; @@ -60,31 +60,34 @@ /** Детерминированное пятно по координате тайла (чистый хэш, без ГПСЧ). */ const mottle = (tx: number, ty: number, salt = 0) => (tx * 7 + ty * 13 + salt * 31) % 11 < 3; -// ---- Генераторы контента игры (деревья/кусты/валуны в тайл 5×5, дома...) ---- +// ---- Генераторы контента игры (деревья/кусты/валуны в тайл 10×10, дома...) ---- -/** Дерево карты: высокое (в ~2 раза выше героя — 11), footprint тайла - * 5×5 (крона не вылезает на соседей — blocked-семантика v1). +/** Дерево карты: высокое (в ~1.7 раза выше героя — 22), footprint тайла + * 10×10 (крона не вылезает на соседей — blocked-семантика v1). * Варианты: 0 круглая крона, 1 узкая колонна, 2 ель. */ export function mapTreeModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x7ee1); - const trunkH = variant === 1 ? rng.range(10, 13) : rng.range(8, 12); - const crownH = variant === 2 ? rng.range(8, 10) : rng.range(6, 9); - const g = new VoxelGrid(5, trunkH + crownH + 1, 5); - for (let y = 0; y < trunkH; y++) g.set(2, y, 2, 4); // ствол - const r = 2 + rng.next() * 0.5; // джиттер радиуса фиксируем заранее + const trunkH = variant === 1 ? rng.range(20, 24) : rng.range(16, 21); + const crownH = variant === 2 ? rng.range(14, 17) : rng.range(11, 15); + const g = new VoxelGrid(10, trunkH + crownH + 1, 10); + for (let y = 0; y < trunkH; y++) { // ствол 2×2 — на этом масштабе 1×1 — щепка + g.set(4, y, 4, 4); g.set(5, y, 4, 4); + g.set(4, y, 5, 4); g.set(5, y, 5, 4); + } + const r = 4 + rng.next() * 1; // джиттер радиуса фиксируем заранее for (let dy = 0; dy < crownH; dy++) { const y = trunkH - 1 + dy; - for (let dz = -2; dz <= 2; dz++) - for (let dx = -2; dx <= 2; dx++) { + for (let dz = -4; dz <= 4; dz++) + for (let dx = -4; dx <= 4; dx++) { const d2 = dx * dx + dz * dz; let ok: boolean; - if (variant === 0) ok = d2 + dy * dy * 0.6 <= r * r + 1; - else if (variant === 1) ok = d2 <= 2; - else ok = d2 <= (dy < crownH / 2 ? 4 : 2); // ель: ярусы сужаются + if (variant === 0) ok = d2 + dy * dy * 0.6 <= r * r + 4; + else if (variant === 1) ok = d2 <= 8; + else ok = d2 <= (dy < crownH / 2 ? 16 : 8); // ель: ярусы сужаются if (!ok) continue; - if (dy > 0 && d2 > 2 && !rng.chance(0.7)) continue; // рваный край + if (dy > 0 && d2 > 8 && !rng.chance(0.7)) continue; // рваный край const light = dy === crownH - 1 || rng.chance(0.25); - g.set(2 + dx, y, 2 + dz, light ? 5 : 3); + g.set(5 + dx, y, 5 + dz, light ? 5 : 3); } } return encodeModel(g, { 3: '#626c4c', 5: '#7d8662', 4: '#5c3b2e' }); @@ -93,105 +96,106 @@ /** Куст карты: низкая крона без ствола, ровно в тайл. */ export function mapBushModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x5a51); - const H = variant === 1 ? 4 : 3; - const g = new VoxelGrid(5, H, 5); + const H = variant === 1 ? 7 : 5; + const g = new VoxelGrid(10, H, 10); for (let y = 0; y < H; y++) - for (let dz = -2; dz <= 2; dz++) - for (let dx = -2; dx <= 2; dx++) { + for (let dz = -4; dz <= 4; dz++) + for (let dx = -4; dx <= 4; dx++) { const d2 = dx * dx + dz * dz; - if (y === 0 ? d2 > 5 : d2 > 2) continue; // низ шире, верх уже + if (y === 0 ? d2 > 20 : d2 > 8) continue; // низ шире, верх уже if (d2 > 0 && !rng.chance(0.8)) continue; - g.set(2 + dx, y, 2 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3); + g.set(5 + dx, y, 5 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3); } return encodeModel(g, { 3: '#626c4c', 5: '#7d8662' }); } -/** Валун карты: глыба-купол в тайл (не ниже 3 — не плитка), свет на верхушке. */ +/** Валун карты: глыба-купол в тайл (не ниже 5 — не плитка), свет на верхушке. */ export function mapBoulderModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x4b0d); - const H = 3 + Math.min(variant, 1) + (rng.next() < 0.4 ? 1 : 0); // 3..5 - const g = new VoxelGrid(5, H, 5); + const H = 5 + Math.min(variant, 1) * 2 + (rng.next() < 0.4 ? 2 : 0); // 5..9 + const g = new VoxelGrid(10, H, 10); for (let y = 0; y < H; y++) { - const w = y === 0 ? 5 : y === H - 1 ? 1 : 3; // купол: шире к низу - const o = (5 - w) >> 1; + const w = y === 0 ? 10 : y === H - 1 ? 3 : 7; // купол: шире к низу + const o = (10 - w) >> 1; for (let dz = 0; dz < w; dz++) for (let dx = 0; dx < w; dx++) { - if (w === 3 && dx === 1 && dz === 1 && rng.chance(0.5)) continue; + if (w === 7 && dx === 3 && dz === 3 && rng.chance(0.5)) continue; g.set(o + dx, y, o + dz, y === H - 1 ? 7 : 6); } } return encodeModel(g, { 6: '#5b5b66', 7: '#94949e' }); } -/** Дом: каменные стены с окнами + двускатная ржавая крыша (в ~1.5 раза выше героя). */ +/** Дом: каменные стены с окнами + двускатная ржавая крыша (~4 тайла — + * вдвое выше героя: строение, в которое заходят, а не будка). */ export function houseModel(kind: number, _seed: number): VoxelModel { const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4; - const W = 12, H = tall ? 18 : 14, D = 9; + const W = 24, H = tall ? 40 : 30, D = 18; const g = new VoxelGrid(W, H, D); - for (let y = 0; y < H - 2; y++) + for (let y = 0; y < H - 4; y++) for (let z = 0; z < D; z++) for (let x = 0; x < W; x++) { const edge = x === 0 || z === 0 || x === W - 1 || z === D - 1; if (!edge) continue; - const win = y > 1 && y < H - 3 && (x + z) % 3 === 0; + const win = y > 2 && y < H - 6 && (x + z) % 5 === 0; g.set(x, y, z, win ? 10 : 6); } - for (let dy = 0; dy < 3; dy++) { - const y = H - 3 + dy; + for (let dy = 0; dy < 4; dy++) { + const y = H - 4 + dy; for (let z = 0; z < D; z++) - for (let x = dy; x < W - dy; x++) g.set(x, y, z, 8); + for (let x = dy * 2; x < W - dy * 2; x++) g.set(x, y, z, 8); } return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e' }); } /** Колодец: каменное кольцо + ворот с брусом (tall 1 юнит). */ export function wellModel(_seed: number): VoxelModel { - const g = new VoxelGrid(5, 6, 5); - for (let z = 0; z < 5; z++) - for (let x = 0; x < 5; x++) { - const edge = x === 0 || z === 0 || x === 4 || z === 4; + const g = new VoxelGrid(10, 12, 10); + for (let z = 0; z < 10; z++) + for (let x = 0; x < 10; x++) { + const edge = x === 0 || z === 0 || x === 9 || z === 9; if (edge) g.set(x, 0, z, 6); } - g.set(0, 1, 0, 4); - g.set(4, 1, 0, 4); - g.set(2, 3, 0, 10); // ворот (металл) - g.set(2, 5, 0, 4); + g.set(0, 2, 0, 4); + g.set(9, 2, 0, 4); + g.set(4, 6, 0, 10); // ворот (металл) + g.set(4, 10, 0, 4); return encodeModel(g, { 6: '#5b5b66', 4: '#5c3b2e', 10: '#44454e' }); } /** Башня звона: колонна с площадкой и колоколом (высокая — доминирует в кадре). */ export function towerModel(_seed: number): VoxelModel { - const g = new VoxelGrid(5, 21, 5); - for (let y = 0; y < 16; y++) - for (let z = 0; z < 5; z++) - for (let x = 0; x < 5; x++) g.set(x, y, z, 6); - for (let x = 0; x < 5; x++) - for (let z = 0; z < 5; z++) g.set(x, 16, z, 7); // площадка, свет камня - g.set(2, 18, 2, 10); // колокол (металл) - g.set(2, 20, 2, 4); + const g = new VoxelGrid(10, 47, 10); + for (let y = 0; y < 36; y++) + for (let z = 0; z < 10; z++) + for (let x = 0; x < 10; x++) g.set(x, y, z, 6); + for (let x = 0; x < 10; x++) + for (let z = 0; z < 10; z++) g.set(x, 36, z, 7); // площадка, свет камня + g.set(4, 40, 4, 10); // колокол (металл) + g.set(4, 45, 4, 4); return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 10: '#44454e', 4: '#5c3b2e' }); } /** Старая Машина: тяжёлый блок механизма (prop 2×2 тайла, выше героя). */ export function machineModel(_seed: number): VoxelModel { - const g = new VoxelGrid(12, 17, 9); - for (let y = 0; y < 11; y++) - for (let z = 0; z < 9; z++) - for (let x = 0; x < 12; x++) + const g = new VoxelGrid(24, 34, 18); + for (let y = 0; y < 22; y++) + for (let z = 0; z < 18; z++) + for (let x = 0; x < 24; x++) g.set(x, y, z, (x + z + y) % 4 === 0 ? 10 : 6); - for (let z = 3; z < 6; z++) - for (let x = 2; x < 10; x++) g.set(x, 11, z, 8); - for (let z = 3; z < 6; z++) g.set(6, 13, z, 10); - g.set(6, 15, 4, 9); // тёплая искра в жерле (жизнь!) + for (let z = 6; z < 12; z++) + for (let x = 4; x < 20; x++) g.set(x, 22, z, 8); + for (let z = 6; z < 12; z++) g.set(12, 26, z, 10); + g.set(12, 30, 8, 9); // тёплая искра в жерле (жизнь!) return encodeModel(g, { 6: '#5b5b66', 8: '#82503a', 10: '#44454e', 9: '#d99a32' }); } /** Бухта кабеля: низкая ржавая спираль (v1 tall 0.6 юнита). */ export function wireModel(_seed: number): VoxelModel { - const g = new VoxelGrid(6, 4, 5); - for (let z = 0; z < 5; z++) - for (let x = 0; x < 6; x++) { - const rim = x === 0 || x === 5 || z === 2; + const g = new VoxelGrid(12, 8, 10); + for (let z = 0; z < 10; z++) + for (let x = 0; x < 12; x++) { + const rim = x === 0 || x === 11 || z === 4; if (rim) g.set(x, 0, z, 8); if (rim && (x + z) % 2 === 0) g.set(x, 1, z, 10); } @@ -214,7 +218,7 @@ const [sx, , sz] = m.size; stampModel( world, m, - tx * UNIT + 2 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 2 - ((sz - 1) >> 1), + tx * UNIT + 5 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 5 - ((sz - 1) >> 1), ); } @@ -223,7 +227,7 @@ * пятна — хэш координат, штампы — генераторы с сидом от координат. */ export function buildAreaWorld(l: AreaLayout): VoxelWorld { - const world = new VoxelWorld(26); // высота чанка 26 (дерево до 23 + запас) + const world = new VoxelWorld(52); // высота чанка 52 (башня до 47 + запас) const tiles = rleDecode(l.tiles, l.width * l.height); // 1) земля (все тайлы) — потом декор, потом штампы (объекты поверх) for (let ty = 0; ty < l.height; ty++) @@ -260,10 +264,10 @@ world: VoxelWorld, d: { id: number; x: number; y: number; dx?: number; dy?: number }, ): void { - const ox = Math.max(-1, Math.min(1, Math.round((d.dx ?? 0) / 3))); - const oz = Math.max(-1, Math.min(1, Math.round((d.dy ?? 0) / 3))); + const ox = Math.max(-2, Math.min(2, Math.round((d.dx ?? 0) / 6))); + const oz = Math.max(-2, Math.min(2, Math.round((d.dy ?? 0) / 6))); const slot = d.id === DECOR.TUFT ? 5 : d.id === DECOR.STONE ? 7 : 2; - world.set(d.x * UNIT + 2 + ox, 1, d.y * UNIT + 2 + oz, slot); + world.set(d.x * UNIT + 5 + ox, 1, d.y * UNIT + 5 + oz, slot); } /** RLE-пары → плоский массив (недостающие — пепел). */ @@ -309,20 +313,25 @@ /** Рельсы: металлическая нить по земле (полоса тайлов даёт линию). */ function stampRail(world: VoxelWorld, tx: number, ty: number): void { - for (let d = 0; d < UNIT; d++) world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 2, RAIL); + for (let d = 0; d < UNIT; d++) { + world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 4, RAIL); + world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 5, RAIL); + } } -/** Колокольчик: цветок на пепле (v1 harvest-тайл). */ +/** Колокольчик: цветок на пепле (v1 harvest-тайл) — столбик в 2 вокселя, + * на тайле 10×10 одиночный воксель был бы точкой. */ function stampBellflower(world: VoxelWorld, tx: number, ty: number): void { - world.set(tx * UNIT + 2, GROUND_Y, ty * UNIT + 2, FLOWER); + world.set(tx * UNIT + 5, GROUND_Y, ty * UNIT + 5, FLOWER); + world.set(tx * UNIT + 5, GROUND_Y + 1, ty * UNIT + 5, FLOWER); } -/** Стена интерьера: колонка камня высотой 8 над полом (обзор сверху сохраняем: - * ниже героя — комнаты читаются, но стена уже не по колено). */ +/** Стена интерьера: колонка камня высотой 13 над полом (обзор сверху + * сохраняем: ниже героя — комнаты читаются, но стена уже не по колено). */ function stampWall(world: VoxelWorld, tx: number, ty: number): void { for (let dz = 0; dz < UNIT; dz++) for (let dx = 0; dx < UNIT; dx++) - for (let y = GROUND_Y; y < GROUND_Y + 8; y++) + for (let y = GROUND_Y; y < GROUND_Y + 13; y++) world.set(tx * UNIT + dx, y, ty * UNIT + dz, 6); } diff --git a/v2/apps/game/src/ash.ts b/v2/apps/game/src/ash.ts index c425da0..0ba81c6 100644 --- a/v2/apps/game/src/ash.ts +++ b/v2/apps/game/src/ash.ts @@ -1,21 +1,29 @@ /** * Атмосфера области (v1 Atmosphere, вариант 'ash'): медленные хлопья пепла - * в воздухе вокруг камеры. Voxel-облако (instanced-кубы рендера): поза - * каждого хлопка — детерминированные синусы от времени цикла (гейт-стабильно), - * дрейф по ветру + лёгкое кружение; хлопья всегда в кадре — облако следует - * за целью камеры (v1: частицы в screen-space, v2 — кубы вокруг target). + * в воздухе. Voxel-облако (instanced-кубы рендера): поза каждого хлопка — + * детерминированные синусы от времени цикла (гейт-стабильно). Хлопки живут + * в МИРОВЫХ координатах (не «приклеены» к камере): каждый дрейфует по ветру + * от своего якоря, а окно видимости вокруг цели камеры их заворачивает — + * ушедший из окна хлопок появляется с противоположной стороны. */ import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; /** Параметры пепла (масштаб мира v2: радиус в вокселях). */ const COUNT = 48; // хлопков в облаке (v1 ~40 частиц) -const RADIUS = 30; // половина ширины облака вокруг камеры (кадр 40×22.5 вокс) -const TOP = 22; // высота верхнего слоя пепла над землёй +const RADIUS = 60; // половина окна вокруг камеры (кадр 80×45 вокс) +const TOP = 44; // высота верхнего слоя пепла над землёй const SPEED = 0.35; // дрейф по ветру (вокс/сек) /** Цвет пепла (палитра сцены: P3 — светлый пепел). */ const ASH_COLOR = '#3e3e48'; +/** Заворачивание значения в окно [min, min+size) — респавн с другой стороны. */ +const wrap = (v: number, min: number, size: number): number => { + let r = (v - min) % size; + if (r < 0) r += size; + return min + r; +}; + export class AshParticles { private readonly cloud: VoxelCloud; @@ -29,21 +37,18 @@ for (let i = 0; i < COUNT; i++) { // сид хлопка — от индекса; фазы кружения разнесены const p = i * 2.399; // золотое сечение: равномерный разброс фаз - // базовый дрейф по ветру: хлопок циклично проходит окно вокруг камеры - const drift = (t * SPEED + i * (RADIUS * 2) / COUNT) % (RADIUS * 2); - const x = target[0] - RADIUS + drift; - const z = target[2] - RADIUS + ((i * 7919) % (RADIUS * 2)); - // вертикаль: медленное оседание + лёгкое кружение по горизонтали + // мировой якорь + дрейф по ветру; окно камеры заворачивает + const x = wrap( + i * 61.7 + t * SPEED + Math.sin(t * 0.7 + p) * 1.5, + target[0] - RADIUS, RADIUS * 2, + ); + const z = wrap( + i * 47.3 + Math.cos(t * 0.5 + p) * 1.5, + target[2] - RADIUS, RADIUS * 2, + ); + // вертикаль: медленное оседание const fall = ((t * (0.25 + (i % 5) * 0.06) + (i * 3.7) % TOP) % TOP); - const y = TOP - fall; - voxels.push({ - pos: [ - x + Math.sin(t * 0.7 + p) * 1.5, - y, - z + Math.cos(t * 0.5 + p) * 1.5, - ], - color: ASH_COLOR, - }); + voxels.push({ pos: [x, TOP - fall, z], color: ASH_COLOR }); } this.cloud.update(voxels); } diff --git a/v2/apps/game/src/deer.ts b/v2/apps/game/src/deer.ts index 81dafcc..27f55e7 100644 --- a/v2/apps/game/src/deer.ts +++ b/v2/apps/game/src/deer.ts @@ -32,8 +32,8 @@ } /** - * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~9 вокселей - * (герой — 11). step — фаза шага (0/1), поднимает диагональные пары ног; + * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~13 вокселей + * (герой — 21). step — фаза шага (0/1), поднимает диагональные пары ног; * dir — направление взгляда (см. rotXZ). */ export function deerVoxels(cx: number, cz: number, step: boolean, dir = 0): DeerVoxel[] { @@ -43,26 +43,26 @@ out.push({ pos: [cx + rx, dy, cz + rz], color }); }; // Ноги: диагональные пары (перед-лево + зад-право) поднимаются на шаге. - const lift = step ? 1 : 0; - for (const [lx, lz] of [[-3, -1], [3, 1], [-3, 1], [3, -1]] as const) { - const up = (lx === -3) === (lz === -1) ? lift : 0; // диагональ шагает - for (let y = 0; y < 3; y++) put(lx, GROUND_Y + y + up, lz, DARK); + const lift = step ? 2 : 0; + for (const [lx, lz] of [[-5, -2], [5, 2], [-5, 2], [5, -2]] as const) { + const up = (lx === -5) === (lz === -2) ? lift : 0; // диагональ шагает + for (let y = 0; y < 6; y++) put(lx, GROUND_Y + y + up, lz, DARK); } - // Туловище: 7×2×3, спина светлее. - for (let dx = -3; dx <= 3; dx++) - for (let dz = -1; dz <= 1; dz++) { - put(dx, GROUND_Y + 3, dz, dx % 2 === 0 ? BODY : DARK); - put(dx, GROUND_Y + 4, dz, LIGHT); + // Туловище: 11×2×5, спина светлее. + for (let dx = -5; dx <= 5; dx++) + for (let dz = -2; dz <= 2; dz++) { + put(dx, GROUND_Y + 6, dz, dx % 2 === 0 ? BODY : DARK); + put(dx, GROUND_Y + 7, dz, LIGHT); } // Шея и голова: вперед-вверх, нос темный, уши-рожки. - put(4, GROUND_Y + 4, 0, BODY); - put(4, GROUND_Y + 5, 0, BODY); - put(5, GROUND_Y + 6, 0, BODY); - put(6, GROUND_Y + 6, 0, DARK); // нос - put(5, GROUND_Y + 7, 0, LIGHT); - put(5, GROUND_Y + 7, -1, ANTLER); - put(5, GROUND_Y + 7, 1, ANTLER); + put(5, GROUND_Y + 8, 0, BODY); + put(6, GROUND_Y + 8, 0, BODY); + put(6, GROUND_Y + 9, 0, BODY); + put(7, GROUND_Y + 9, 0, DARK); // нос + put(6, GROUND_Y + 10, 0, LIGHT); + put(6, GROUND_Y + 11, -1, ANTLER); + put(6, GROUND_Y + 11, 1, ANTLER); // Хвост. - put(-4, GROUND_Y + 4, 0, LIGHT); + put(-6, GROUND_Y + 7, 0, LIGHT); return out; } \ No newline at end of file diff --git a/v2/apps/game/src/flowers.ts b/v2/apps/game/src/flowers.ts index c8d1b7f..0805fd4 100644 --- a/v2/apps/game/src/flowers.ts +++ b/v2/apps/game/src/flowers.ts @@ -17,7 +17,7 @@ /** Воксель цветка тайла (центр, как stampBellflower в areas.ts). */ function flowerVoxel(tx: number, ty: number): [number, number, number] { - return [tx * UNIT + 1, GROUND_Y, ty * UNIT + 1]; + return [tx * UNIT + 5, GROUND_Y, ty * UNIT + 5]; } /** Правки цветков области: тайл → стоит ли цветок (переживает пересборку). */ diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index aace72a..6840253 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -4,7 +4,7 @@ * step/click с фейдом и стек возврата, NPC с диалогами по квест-стадиям, * сумка, журнал квестов, агентный мост для гейтов. Позиции v1 (спавны/ * переходы/NPC, в тайлах) конвертируются на границе: tileCenter - * (tile*UNIT + 1.5). Референс — демка v1. + * (tile*UNIT + 5). Референс — демка v1. */ import { VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, @@ -64,7 +64,7 @@ // ---- Рендер: мир области (пересоздаётся на переходе), меш на чанк ---- const canvas = document.getElementById('scene') as HTMLCanvasElement; -const renderer = new VoxelRenderer(canvas, { target: [40, 2, 60], width: 960, height: 540, viewSize: 40 }); +const renderer = new VoxelRenderer(canvas, { target: [80, 2, 120], width: 960, height: 540, viewSize: 80 }); // ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ---- const cutsceneActions: Record = { @@ -109,13 +109,13 @@ // ---- Герой: манекен + walk-цикл (как в демке) ---- const { model: MAN, rig: MAN_RIG } = mannequin(); -const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.25, sway: 0.15 }); -const BODY_SIZE: [number, number, number] = [3, 10, 3]; +const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.5, sway: 0.3 }); +const BODY_SIZE: [number, number, number] = [5, 21, 5]; -const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], { +const hero = new Character(MAN_RIG, MAN, WALK, [80, 120], { size: BODY_SIZE, - speed: 10.5, // v1: 1.75 юнита/с × UNIT 5 + фидбек «быстрее» → 2.1 юнита/с - strideLength: 6.4, // темп шага как в v1 (1.64 цикла/с при 2.1 юнита/с) + speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с + strideLength: 12.8, // темп шага как в v1 (1.64 цикла/с при 2.1 юнита/с) }); const heroCloud = renderer.addVoxelCloud(hero.visual()); @@ -207,7 +207,7 @@ } // ---- NPC: статичные по области + размещения по флагам (Клинт) ---- -const NPC_RADIUS = 7.5; // радиус подхода (v1: 1.5 юнита; расчёты расстановки NPC v1 держат этот радиус) +const NPC_RADIUS = 15; // радиус подхода (v1: 1.5 юнита; расчёты расстановки NPC v1 держат этот радиус) interface NpcEntity { def: NpcDef; /** Точка взаимодействия (движковый формат; выбор цели — pickInteract). */ @@ -221,7 +221,7 @@ function spawnNpc(def: NpcDef): NpcEntity { const c = tileCenter(def.tile.x, def.tile.y); const char = new Character(MAN_RIG, MAN, WALK, [c.x, c.z], { - size: BODY_SIZE, speed: 0, strideLength: 6.4, + size: BODY_SIZE, speed: 0, strideLength: 12.8, }); char.body.pos = [c.x, GROUND_Y, c.z]; return { @@ -403,7 +403,7 @@ flower: 3, }; /** Эпсилон ничьей (воксели; v1: 0.05 юнита × UNIT). */ -const PICK_EPS = 0.25; +const PICK_EPS = 0.5; /** Выбор цели «E» (v1 pickInteract): ближайший кандидат; при ничьей — приоритет kind. */ function pickInteract(cands: PickCandidate[]): PickCandidate | null { @@ -760,8 +760,8 @@ return { x: Math.floor(b[0] / UNIT), y: Math.floor(b[2] / UNIT) }; } -/** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 7.5 вокселя. */ -const INTERACT_RANGE = 7.5; +/** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 15 вокселей. */ +const INTERACT_RANGE = 15; /** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */ function tick(dt: number): void { diff --git a/v2/apps/game/src/props.ts b/v2/apps/game/src/props.ts index b5b539f..93ce1fc 100644 --- a/v2/apps/game/src/props.ts +++ b/v2/apps/game/src/props.ts @@ -1,7 +1,7 @@ /** * Воксельные пропсы интерактивов (визуал на месте «вьюх» v1): столбы у * троп, моты (тёплая искра — жизнь!), сундук, записка, прилавок, верёвка - * колокола, резонатор. Штамп строго в тайл 5×5 (урок трека 15: вылезать + * колокола, резонатор. Штамп строго в тайл 10×10 (урок трека 15: вылезать * на соседей нельзя). clearProp снимает проп и возвращает землю. */ import type { VoxelWorld } from '@rpg/engine'; @@ -24,47 +24,44 @@ /** Поставить проп интерактива в его тайл (детерминированно, без ГПСЧ). */ export function stampProp(world: VoxelWorld, def: InteractableDef): void { if (!hasProp(def)) return; - const bx = def.tile.x * UNIT + 2; // центр тайла - const bz = def.tile.y * UNIT + 2; + const bx = def.tile.x * UNIT + 4; // центр тайла (10×10) + const bz = def.tile.y * UNIT + 4; if (def.kind === 'pickup') { // мот: тёплая искра-столбик (видна издали) - world.set(bx, GROUND_Y, bz, SPARK); - world.set(bx, GROUND_Y + 1, bz, SPARK); + for (let y = 0; y < 3; y++) world.set(bx, GROUND_Y + y, bz, SPARK); return; } if (def.kind === 'signpost') { - world.set(bx, GROUND_Y, bz, WOOD); // столб - world.set(bx, GROUND_Y + 1, bz, WOOD); - world.set(bx, GROUND_Y + 2, bz, WOOD_L); // доска + for (let y = 0; y < 4; y++) world.set(bx, GROUND_Y + y, bz, WOOD); // столб + for (let dx = -1; dx <= 1; dx++) world.set(bx + dx, GROUND_Y + 4, bz, WOOD_L); // доска return; } if (def.kind === 'container') { - // сундук: деревянный ящик 2×2×2 с металлической кромкой - world.set(bx, GROUND_Y, bz, WOOD); - world.set(bx + 1, GROUND_Y, bz, WOOD); - world.set(bx, GROUND_Y, bz + 1, WOOD); - world.set(bx + 1, GROUND_Y, bz + 1, WOOD); - world.set(bx, GROUND_Y + 1, bz, METAL); + // сундук: деревянный ящик 4×3×3 с металлической крышкой + for (let y = 0; y < 2; y++) + for (let dz = 0; dz < 3; dz++) + for (let dx = 0; dx < 4; dx++) world.set(bx + dx, GROUND_Y + y, bz + dz, WOOD); + for (let dz = 0; dz < 3; dz++) + for (let dx = 0; dx < 4; dx++) world.set(bx + dx, GROUND_Y + 2, bz + dz, METAL); return; } if (def.id === 'note_elder') { world.set(bx, GROUND_Y, bz, PAPER); // записка: светлый клочок на полу + world.set(bx + 1, GROUND_Y, bz, PAPER); return; } if (def.id === 'bell_rope') { // верёвка у башни: столбик с деревянной ручкой - world.set(bx, GROUND_Y, bz, METAL); - world.set(bx, GROUND_Y + 1, bz, METAL); - world.set(bx, GROUND_Y + 2, bz, WOOD); + for (let y = 0; y < 4; y++) world.set(bx, GROUND_Y + y, bz, METAL); + world.set(bx, GROUND_Y + 4, bz, WOOD); return; } if (def.id === 'clint_resonator') { // резонатор: медный короб на стальной раме - world.set(bx, GROUND_Y, bz, METAL); - world.set(bx + 1, GROUND_Y, bz, METAL); - world.set(bx + 2, GROUND_Y, bz, METAL); - world.set(bx, GROUND_Y + 1, bz, WOOD_L); - world.set(bx + 1, GROUND_Y + 1, bz, WOOD_L); + for (let y = 0; y < 2; y++) + for (let dx = 0; dx < 5; dx++) world.set(bx + dx, GROUND_Y + y, bz, METAL); + for (let y = 2; y < 4; y++) + for (let dx = 0; dx < 4; dx++) world.set(bx + dx, GROUND_Y + y, bz, WOOD_L); return; } } @@ -74,6 +71,6 @@ if (!hasProp(def)) return; for (let dz = 0; dz < UNIT; dz++) for (let dx = 0; dx < UNIT; dx++) - for (let y = GROUND_Y; y <= GROUND_Y + 2; y++) + for (let y = GROUND_Y; y <= GROUND_Y + 5; y++) world.set(def.tile.x * UNIT + dx, y, def.tile.y * UNIT + dz, 0); } \ No newline at end of file diff --git a/v2/apps/game/tools/checks/act2.mjs b/v2/apps/game/tools/checks/act2.mjs index 1f14d02..9ceeb3d 100644 --- a/v2/apps/game/tools/checks/act2.mjs +++ b/v2/apps/game/tools/checks/act2.mjs @@ -23,7 +23,7 @@ }; /** Центр тайла (в тайлах v1) — как мост tileCenter. */ -const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 }); +const center = (t) => ({ x: t.x * 10 + 5, z: t.y * 10 + 5 }); /** Тон-деревья рощи (data/quests). */ const TREES = [[32, 6], [12, 10], [20, 30]]; @@ -48,7 +48,7 @@ check('без act2_hook: Клинта нет в роще', ni.npcs.length === 0, ni.npcs); const t2 = center({ x: TREES[1][0], y: TREES[1][1] }); - await client.call('heroTeleport', t2.x + 5, t2.z); + await client.call('heroTeleport', t2.x + 10, t2.z); await client.call('stepTicks', 3); await client.call('interact'); let di = await client.call('dialogueInfo'); @@ -76,7 +76,7 @@ await client.call('gotoArea', 'machine_hall'); await client.call('stepTicks', 5); const mach = center({ x: 13, y: 1 }); - await client.call('heroTeleport', mach.x, mach.z + 5); + await client.call('heroTeleport', mach.x, mach.z + 10); await client.call('stepTicks', 3); await client.call('interact'); di = await client.call('dialogueInfo'); @@ -87,7 +87,7 @@ await client.call('stepTicks', 5); for (const [i, [tx, ty]] of TREES.entries()) { const c = center({ x: tx, y: ty }); - await client.call('heroTeleport', c.x + 5, c.z); + await client.call('heroTeleport', c.x + 10, c.z); await client.call('stepTicks', 3); await client.call('interact'); di = await client.call('dialogueInfo'); @@ -96,7 +96,7 @@ { tones: di.vars.tones, flag: di.flags.includes(`tone_tree_${i + 1}`) }); } const c1 = center({ x: TREES[0][0], y: TREES[0][1] }); // повтор дерева 1 - await client.call('heroTeleport', c1.x + 5, c1.z); + await client.call('heroTeleport', c1.x + 10, c1.z); await client.call('stepTicks', 3); await client.call('interact'); di = await client.call('dialogueInfo'); @@ -106,7 +106,7 @@ // ---- Резонатор до настройки: молчит ---- const rez = center({ x: 24, y: 16 }); - await client.call('heroTeleport', rez.x + 5, rez.z); + await client.call('heroTeleport', rez.x + 10, rez.z); await client.call('stepTicks', 3); await client.call('interact'); di = await client.call('dialogueInfo'); @@ -128,7 +128,7 @@ const hi = await client.call('heroInfo'); cs = await client.call('cutsceneInfo'); check('катсцена: герой стоит, камера едет к дереву рощи', - cs.active && cs.step === 2 && hi.camTarget[0] > 63 && hi.camTarget[0] < 127 + cs.active && cs.step === 2 && hi.camTarget[0] > 125 && hi.camTarget[0] < 255 && hi.tile.x === 25 && hi.tile.y === 16, { step: cs.step, cam: hi.camTarget, tile: hi.tile }); await client.call('stepTicks', 300); @@ -137,7 +137,7 @@ const hiEnd = await client.call('heroInfo'); check('конец катсцены: тост «Роща слышит», камера вернулась к герою', !cs.active && di.toasts.some((t) => t.includes('Роща слышит')) - && Math.round(hiEnd.camTarget[0]) === Math.round(rez.x + 5), { toasts: di.toasts, cam: hiEnd.camTarget }); + && Math.round(hiEnd.camTarget[0]) === Math.round(rez.x + 10), { toasts: di.toasts, cam: hiEnd.camTarget }); // ---- После настройки: реакции в лад ---- await client.call('interact'); // герой ещё у резонатора @@ -152,7 +152,7 @@ // ---- Усыпление Машины: катсцена «правильного звона» ---- await client.call('gotoArea', 'machine_hall'); await client.call('stepTicks', 5); - await client.call('heroTeleport', mach.x, mach.z + 5); + await client.call('heroTeleport', mach.x, mach.z + 10); await client.call('stepTicks', 3); await client.call('interact'); let cs2 = await client.call('cutsceneInfo'); @@ -184,14 +184,14 @@ // Подход к звону: звон в 1 тайле от первого оленя const d0 = fi2.deer[0]; - await client.call('startleFauna', d0.x + 5, d0.z); + await client.call('startleFauna', d0.x + 10, d0.z); fi = await client.call('faunaInfo'); check('звон рядом: олень идёт к источнику (approach)', fi.deer[0].state === 'approach', fi.deer[0]); await client.call('stepTicks', 120); // 1 тайл ≈ 1.5 с на скорости подхода fi = await client.call('faunaInfo'); check('олень дошёл до звона и стоит', - fi.deer[0].state === 'idle' && Math.abs(fi.deer[0].x - (d0.x + 5)) < 0.1 + fi.deer[0].state === 'idle' && Math.abs(fi.deer[0].x - (d0.x + 10)) < 0.1 && Math.abs(fi.deer[0].z - d0.z) < 0.1, fi.deer[0]); // Кадр оленя у воды (визуальный артефакт) diff --git a/v2/apps/game/tools/checks/areas.mjs b/v2/apps/game/tools/checks/areas.mjs index 9991ed3..beeafeb 100644 --- a/v2/apps/game/tools/checks/areas.mjs +++ b/v2/apps/game/tools/checks/areas.mjs @@ -48,8 +48,8 @@ throw new Error(`${id}: тайл ${tileId} не найден`); } -/** Воксельный центр тайла (как tileCenter в игре: tile*UNIT + 2.5). */ -const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 }); +/** Воксельный центр тайла (как tileCenter в игре: tile*UNIT + 5). */ +const center = (t) => ({ x: t.x * 10 + 5, z: t.y * 10 + 5 }); function countDiff(a, b) { let n = 0; @@ -140,7 +140,7 @@ { tree: sTree, water: sWater, path: sPath }); // Тело зажимается гранью воды (вода — сплошной слой на уровне ног) - await client.call('heroTeleport', water.x - 5, water.z); + await client.call('heroTeleport', water.x - 10, water.z); await client.call('setInput', 1, 1); await client.call('stepTicks', CROSS); await client.call('setInput', null, null); @@ -172,7 +172,7 @@ await client.call('gotoArea', 'meadows'); await client.call('stepTicks', 5); const trig = center({ x: 54, y: 28 }); - await client.call('heroTeleport', trig.x - 5, trig.z); + await client.call('heroTeleport', trig.x - 10, trig.z); await client.call('setInput', 1, 1); await client.call('stepTicks', CROSS); await client.call('setInput', null, null); @@ -185,7 +185,7 @@ await client.call('gotoArea', 'meadows'); await client.call('stepTicks', 5); const pTrig = center({ x: 2, y: 2 }); - await client.call('heroTeleport', pTrig.x + 5, pTrig.z); + await client.call('heroTeleport', pTrig.x + 10, pTrig.z); await client.call('setInput', -1, -1); await client.call('stepTicks', CROSS); await client.call('setInput', null, null); @@ -205,7 +205,7 @@ await client.call('gotoArea', 'old_siding'); await client.call('stepTicks', 5); const hatch = center({ x: 28, y: 31 }); - await client.call('heroTeleport', hatch.x - 5, hatch.z); + await client.call('heroTeleport', hatch.x - 10, hatch.z); await client.call('setInput', 1, 1); await client.call('stepTicks', CROSS); await client.call('setInput', null, null); @@ -230,7 +230,7 @@ await client.call('gotoArea', 'zvenets'); await client.call('stepTicks', 5); const door = center({ x: 13, y: 12 }); - await client.call('heroTeleport', door.x + 5, door.z); + await client.call('heroTeleport', door.x + 10, door.z); await client.call('stepTicks', 5); let ii = await client.call('interactInfo'); check('подсказка у двери: «E — Дом Ирвина»', ii.label === 'Дом Ирвина' && ii.hint.includes('E'), ii); @@ -238,7 +238,7 @@ await client.call('stepTicks', 5); ii = await client.call('interactInfo'); check('вдали от двери подсказки нет', ii.label === null && ii.hint === '', ii); - await client.call('heroTeleport', door.x + 5, door.z); + await client.call('heroTeleport', door.x + 10, door.z); await client.call('stepTicks', 5); await client.call('interact'); await client.call('stepTicks', PASS); diff --git a/v2/apps/game/tools/checks/interact.mjs b/v2/apps/game/tools/checks/interact.mjs index c2459f5..3677fe1 100644 --- a/v2/apps/game/tools/checks/interact.mjs +++ b/v2/apps/game/tools/checks/interact.mjs @@ -31,10 +31,10 @@ return n; } -/** Центр тайла v1 в вокселях (tile*5 + 2.5). */ -const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 }); +/** Центр тайла v1 в вокселях (tile*10 + 5). */ +const center = (t) => ({ x: t.x * 10 + 5, z: t.y * 10 + 5 }); /** Воксель цветка/мота тайла ([tx*5+2, GROUND_Y, ty*5+2]). */ -const spotVoxel = (t) => [t.x * 5 + 2, 2, t.y * 5 + 2]; +const spotVoxel = (t) => [t.x * 10 + 5, 2, t.y * 10 + 5]; /** Прошагать с зажатым стиком и вернуть пройденную дистанцию по x. */ async function walkX(client, sx, sy, ticks) { @@ -60,7 +60,7 @@ // ---- Луга: столб у тропы (подсказка + реакция-тост) ---- const signC = center({ x: 48, y: 27 }); - await client.call('heroTeleport', signC.x - 5, signC.z); + await client.call('heroTeleport', signC.x - 10, signC.z); await client.call('stepTicks', 3); const ii1 = await client.call('interactInfo'); check('столб у тропы: кандидат «interact», подсказка с подписью', @@ -77,7 +77,7 @@ const moteC = center(moteTile); const mv = spotVoxel(moteTile); const motBefore = await client.call('voxAt', mv[0], mv[1], mv[2]); - await client.call('heroTeleport', moteC.x + 5, moteC.z); + await client.call('heroTeleport', moteC.x + 10, moteC.z); await client.call('stepTicks', 3); const mv2 = await client.call('dialogueInfo'); await client.call('interact'); @@ -97,7 +97,7 @@ await client.call('gotoArea', 'ponds'); await client.call('stepTicks', 5); const pSignC = center({ x: 5, y: 3 }); - await client.call('heroTeleport', pSignC.x + 5, pSignC.z); + await client.call('heroTeleport', pSignC.x + 10, pSignC.z); await client.call('stepTicks', 3); await client.call('interact'); const st2 = await client.call('dialogueInfo'); @@ -108,7 +108,7 @@ const flC = center(flTile); const fv = spotVoxel(flTile); const flBefore = await client.call('voxAt', fv[0], fv[1], fv[2]); - await client.call('heroTeleport', flC.x + 5, flC.z); + await client.call('heroTeleport', flC.x + 10, flC.z); await client.call('stepTicks', 3); const ii3 = await client.call('interactInfo'); check('цветок: кандидат «flower» с тайлом и подсказкой', @@ -139,7 +139,7 @@ await client.call('gotoArea', 'house_elder'); await client.call('stepTicks', 5); const chestC = center({ x: 3, y: 3 }); - await client.call('heroTeleport', chestC.x + 5, chestC.z); + await client.call('heroTeleport', chestC.x + 10, chestC.z); await client.call('stepTicks', 3); await client.call('interact'); const chest = await client.call('dialogueInfo'); @@ -148,7 +148,7 @@ && (await client.call('voxAt', chestC.x + 0.5, 2, chestC.z - 0.5)) === 0, { inv: chest.inv.map((s) => s.id) }); const noteC = center({ x: 10, y: 4 }); - await client.call('heroTeleport', noteC.x + 5, noteC.z); + await client.call('heroTeleport', noteC.x + 10, noteC.z); await client.call('stepTicks', 3); await client.call('interact'); const note = await client.call('dialogueInfo'); @@ -156,7 +156,7 @@ note.flags.includes('read_note') && note.toasts.some((t) => t.includes('Молчание')), {}); const hearthC = center({ x: 8, y: 0 }); - await client.call('heroTeleport', hearthC.x + 5, hearthC.z + 2.5); + await client.call('heroTeleport', hearthC.x + 10, hearthC.z + 5); await client.call('stepTicks', 3); await client.call('interact'); const hearth = await client.call('dialogueInfo'); @@ -178,7 +178,7 @@ await client.call('gotoArea', 'zvenets'); await client.call('stepTicks', 5); const ropeC = center({ x: 27, y: 13 }); - await client.call('heroTeleport', ropeC.x + 5, ropeC.z); + await client.call('heroTeleport', ropeC.x + 10, ropeC.z); await client.call('stepTicks', 3); await client.call('interact'); const rope1 = await client.call('dialogueInfo'); @@ -242,7 +242,7 @@ await client.call('gotoArea', 'machine_hall'); await client.call('stepTicks', 5); const machC = center({ x: 13, y: 1 }); - await client.call('heroTeleport', machC.x, machC.z + 5); + await client.call('heroTeleport', machC.x, machC.z + 10); await client.call('stepTicks', 3); await client.call('interact'); const mach1 = await client.call('dialogueInfo'); @@ -263,7 +263,7 @@ // ---- Очаг после сдачи: ветка с флагом ---- await client.call('gotoArea', 'house_elder'); await client.call('stepTicks', 5); - await client.call('heroTeleport', hearthC.x + 5, hearthC.z + 2.5); + await client.call('heroTeleport', hearthC.x + 10, hearthC.z + 5); await client.call('stepTicks', 3); await client.call('interact'); const hearth2 = await client.call('dialogueInfo'); diff --git a/v2/apps/game/tools/checks/npc.mjs b/v2/apps/game/tools/checks/npc.mjs index 280a5e3..7e008ee 100644 --- a/v2/apps/game/tools/checks/npc.mjs +++ b/v2/apps/game/tools/checks/npc.mjs @@ -37,8 +37,8 @@ return seen.size; } -/** Воксельный центр тайла v1 (как tileCenter: tile*5 + 2.5). */ -const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 }); +/** Воксельный центр тайла v1 (как tileCenter: tile*10 + 5). */ +const center = (t) => ({ x: t.x * 10 + 5, z: t.y * 10 + 5 }); try { await server.waitReady; @@ -75,7 +75,7 @@ // Подход к Ирвину: радиус 5 вокселей → near + подсказка «E — Поговорить» const elderC = center({ x: 12, y: 15 }); - await client.call('heroTeleport', elderC.x - 5, elderC.z); + await client.call('heroTeleport', elderC.x - 10, elderC.z); await client.call('stepTicks', 3); const near1 = await client.call('npcInfo'); const ii1 = await client.call('interactInfo'); @@ -134,7 +134,7 @@ // Мила: знакомство trader_first (полотно) — до сдачи цветов: после // сдачи активная стадия «bells» переключит Милу на trader_after const traderC = center({ x: 40, y: 20 }); - await client.call('heroTeleport', traderC.x - 5, traderC.z); + await client.call('heroTeleport', traderC.x - 10, traderC.z); await client.call('stepTicks', 3); const dMila = await client.call('talkTo', 'trader'); check('Мила: знакомство trader_first', dMila.graphId === 'trader_first', dMila.graphId); @@ -182,7 +182,7 @@ && clintInfo.npcs[0].tile.x === 23 && clintInfo.npcs[0].tile.y === 17, { npcs: clintInfo.npcs.map((n) => n.id) }); const clintC = center({ x: 23, y: 17 }); - await client.call('heroTeleport', clintC.x - 5, clintC.z); + await client.call('heroTeleport', clintC.x - 10, clintC.z); await client.call('stepTicks', 3); const dClint = await client.call('talkTo', 'clint'); for (let i = 0; i < 5; i++) await client.call('dialogueAdvance'); // grunt→…→reply diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts index 328567b..efac4f9 100644 --- a/v2/packages/engine/src/models/__tests__/mannequin.test.ts +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -13,17 +13,17 @@ expect(validateRig(m.rig, m.model)).toEqual([]); }); - it('состав частей: 5 ног ×2, 4 руки ×2, торс 12, голова 2', () => { + it('состав частей: ноги 36 ×2, руки 24 ×2, торс 70, голова 30', () => { const { model, rig } = mannequin(); const g = decodeModel(model); - expect(g.count()).toBe(32); // 10 ног + 12 торс + 2 голова + 8 рук + expect(g.count()).toBe(220); // 72 ноги + 48 рук + 70 торс + 30 голова const byBone = [0, 0, 0, 0, 0]; for (let i = 0; i < g.data.length; i++) if (g.data[i] !== 0) byBone[rig.binding[i]]++; - expect(byBone).toEqual([14, 5, 5, 4, 4]); // root: торс+голова + expect(byBone).toEqual([100, 36, 36, 24, 24]); // root: торс+голова }); - it('каждая часть — связная колонна (6-связность)', () => { + it('каждая часть — связное тело (6-связность)', () => { const { model, rig } = mannequin(); const g = decodeModel(model); const cells = (bone: number) => { @@ -33,14 +33,25 @@ const x = i % g.sx, y = Math.floor(i / g.sx) % g.sy, z = Math.floor(i / (g.sx * g.sy)); out.push([x, y, z]); } - return out.sort((a, b) => a[1] - b[1]); + return out; }; for (const bone of [1, 2, 3, 4]) { const col = cells(bone); expect(col.length).toBeGreaterThan(3); - for (let k = 1; k < col.length; k++) { - expect(Math.abs(col[k][0] - col[k - 1][0]) + Math.abs(col[k][1] - col[k - 1][1]) + Math.abs(col[k][2] - col[k - 1][2])).toBe(1); + // BFS от первой клетки: все воксели части достижимы соседством граней + const seen = new Set([col[0]!.join(',')]); + const queue = [col[0]!]; + while (queue.length) { + const [x, y, z] = queue.shift()!; + for (const [nx, ny, nz] of [[x + 1, y, z], [x - 1, y, z], [x, y + 1, z], [x, y - 1, z], [x, y, z + 1], [x, y, z - 1]]) { + const key = `${nx},${ny},${nz}`; + if (seen.has(key)) continue; + if (!col.some((c) => c[0] === nx && c[1] === ny && c[2] === nz)) continue; + seen.add(key); + queue.push([nx, ny, nz]); + } } + expect(seen.size).toBe(col.length); } }); diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts index 64cf9d5..be4ed5a 100644 --- a/v2/packages/engine/src/models/mannequin.ts +++ b/v2/packages/engine/src/models/mannequin.ts @@ -1,9 +1,11 @@ /** * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6). - * Деревянный болванчик 5×11×3: ноги (P4), торс (R1), голова (P6), - * руки (R2 — ржавые, видны в махе). Части известны при генерации, поэтому - * привязка к костям передаётся явно (bindNearest здесь не нужен: торс - * рядом с плечами и Voronoi по pivot отнёс бы его к рукам). + * Деревянный болванчик 11×21×4 (масштаб мира UNIT=10: герой ~2 тайла + * ростом — на кубической голове/торсе/конечностях силуэт читается): + * ноги (P4), торс (R1), голова (P6), руки (R2 — ржавые, видны в махе). + * Части известны при генерации, поэтому привязка к костям передаётся + * явно (bindNearest здесь не нужен: торс рядом с плечами и Voronoi + * по pivot отнёс бы его к рукам). */ import { VoxelGrid } from '../voxel/grid'; import { encodeModel } from './format'; @@ -27,36 +29,42 @@ /** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ export function mannequin(): Mannequin { - const g = new VoxelGrid(5, 11, 3); + const g = new VoxelGrid(11, 21, 4); // привязка: индекс вокселя → индекс кости (root=0, legL=1, legR=2, armL=3, armR=4) const binding = new Array(g.data.length).fill(-1); const put = (x: number, y: number, z: number, slot: number, bone: number): void => { g.set(x, y, z, slot); binding[x + g.sx * (y + g.sy * z)] = bone; }; - // ноги: колонны x=1 и x=3, y=0..4 - for (let y = 0; y <= 4; y++) { - put(1, y, 1, LEG, 1); - put(3, y, 1, LEG, 2); - } - // торс: x=1..3, y=5..8; голова: x=2, y=9..10 - for (let y = 5; y <= 8; y++) - for (let x = 1; x <= 3; x++) put(x, y, 1, TORSO, 0); - put(2, 9, 1, HEAD, 0); - put(2, 10, 1, HEAD, 0); - // руки: колонны x=0 и x=4, y=5..8 (свободно висят) - for (let y = 5; y <= 8; y++) { - put(0, y, 1, ARM, 3); - put(4, y, 1, ARM, 4); - } + // ноги: колонны 2×2 (x3..4 и x6..7), y=0..8 — между ними просвет + for (let y = 0; y <= 8; y++) + for (let z = 1; z <= 2; z++) + for (let dx = 0; dx <= 1; dx++) { + put(3 + dx, y, z, LEG, 1); + put(6 + dx, y, z, LEG, 2); + } + // торс: x=3..7, y=9..15; голова: x=4..6, y=16..20 (куб на плечах) + for (let y = 9; y <= 15; y++) + for (let z = 1; z <= 2; z++) + for (let x = 3; x <= 7; x++) put(x, y, z, TORSO, 0); + for (let y = 16; y <= 20; y++) + for (let z = 1; z <= 2; z++) + for (let x = 4; x <= 6; x++) put(x, y, z, HEAD, 0); + // руки: колонны 2×2 по бокам торса, y=9..14 (свободно висят) + for (let y = 9; y <= 14; y++) + for (let z = 1; z <= 2; z++) + for (let dx = 0; dx <= 1; dx++) { + put(1 + dx, y, z, ARM, 3); + put(8 + dx, y, z, ARM, 4); + } const model = encodeModel(g, { 4: '#5c3b2e', 6: '#5b5b66', 7: '#94949e', 8: '#82503a' }); const rig = makeRig( [ - { name: 'root', parent: -1, pivot: [2.5, 0, 1.5] }, // земля, центр пят - { name: 'legL', parent: 0, pivot: [1.5, 5, 1.5] }, // бедро левой - { name: 'legR', parent: 0, pivot: [3.5, 5, 1.5] }, // бедро правой - { name: 'armL', parent: 0, pivot: [0.5, 9, 1.5] }, // плечо левой - { name: 'armR', parent: 0, pivot: [4.5, 9, 1.5] }, // плечо правой + { name: 'root', parent: -1, pivot: [5.5, 0, 2] }, // земля, центр пят + { name: 'legL', parent: 0, pivot: [4, 9, 2] }, // бедро левой + { name: 'legR', parent: 0, pivot: [7, 9, 2] }, // бедро правой + { name: 'armL', parent: 0, pivot: [2, 15, 2] }, // плечо левой + { name: 'armR', parent: 0, pivot: [9, 15, 2] }, // плечо правой ], model, binding, diff --git a/v2/packages/engine/src/physics/__tests__/character.test.ts b/v2/packages/engine/src/physics/__tests__/character.test.ts index dd46dfe..6154760 100644 --- a/v2/packages/engine/src/physics/__tests__/character.test.ts +++ b/v2/packages/engine/src/physics/__tests__/character.test.ts @@ -66,12 +66,12 @@ c.setMove([1, 0]); for (let i = 0; i < 30; i++) c.step(w, 1 / 60); const voxels = c.visual(); - expect(voxels.length).toBe(32); // все воксели манекена + expect(voxels.length).toBe(220); // все воксели манекена for (const v of voxels) { - expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(2.5); // модель 5 шириной + expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(5.5); // модель 11 шириной expect(v.pos[1]).toBeGreaterThanOrEqual(c.body.pos[1]); } const idle = makeChar(); - expect(idle.visual().length).toBe(32); + expect(idle.visual().length).toBe(220); }); }); \ No newline at end of file