diff --git a/docs/plan.md b/docs/plan.md index 1c2557c..acf05a3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -761,6 +761,20 @@ (~2×, UNIT=3), тон тёмных областей светлее (пруды/роща/дом без сумеречного затемнения v1), вода бледнее, интерьер дома беднее деталями (полки/камин v1 — пропы строго в тайл 3×3). +- **Трек 22 — масштаб мира UNIT 3→5** (ЗАКРЫТ 2026-09-10, фидбек: + «персонажи крупнее деревьев и соразмерны домам»): 1 тайл v1 теперь + 5×5 вокселей (герой 11 ≈ 2 тайла ростом — дерево ~2× выше героя, дом + в ~1.5×). Все генераторы переписаны под тайл 5×5 и новые высоты + (дерево 17–23, дом 12×14–18×9, башня 21, Машина 12×17×9, колодец, + кусты/валуны), пропы и олень подросли соразмерно; стена интерьера 8 + (ниже героя — комнаты читаются). Скорости: герой 2.1 юнита/с (v1 1.75 + + фидбек «быстрее»), олени 0.45/1.0 юнита/с. Камера viewSize 40 — тот + же охват 8 тайлов, что был при UNIT=3. Высота мира 20→26. Гейты: + дубль tileCenter в гейтах был с хардкодом ×3 — заменён на ×5+2.5, + подходы ±3→±5, диапазон камеры катсцены tune пересчитан. Все 6 гейтов + игры зелёные, 295 тестов. Грабля: центр тайла при нечётном UNIT — + `tile*UNIT + 2`, не 1.5; рельсы — нить d=0..UNIT-1 (была с дырками); + spotVoxel тайла — tile*5+2. - **Раунд полировки** (2026-09-10, «улучшим игру технически и визуально»): недоделки визуального слоя трека 18 + техполировка. Меню (Esc) + автосейв/«Продолжить» (движковый SaveManager, localStorage `rpg2:`; diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 6899896..eda3cdd 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -751,6 +751,24 @@ v1-тулза умеет переходы пешком/телепортом к тайлу рядом с триггером (телепорт точно на step-триггер не срабатывает — как в v2). +## Трек 22 (2026-09-10): масштаб мира UNIT 3→5 + +Фидбек «персонажи крупнее деревьев и соразмерны домам» — 1 тайл v1 теперь +5×5 вокселей (герой 11 ≈ 2 тайла; дерево ~2× выше героя, дом ~1.5×). +- `areas.ts`: UNIT=5, генераторы под тайл 5×5 и новые высоты (дерево + 17–23, дом 12×14–18×9, башня 21, Машина 12×17×9), высота мира 26, + стены интерьера 8 (ниже героя — комнаты читаются). Центр тайла — + `tile*UNIT + 2` (при нечётном UNIT), рельсы — нить `d=0..UNIT-1` + непрерывно (старый `d=1..` оставлял дырки), spotVoxel — `tile*5+2`. +- Герой: speed 10.5 (2.1 юнита/с = v1 1.75 + фидбек «быстрее»), stride + 6.4 (темп шага v1 сохранён); олень подрос (7→9 вокс), скорости фауны + 0.45/1.0 юнита/с. Камера viewSize 40 — тот же охват 8 тайлов, что был + при UNIT=3 (тени/дистанция камеры масштабируются от viewSize сами). +- Гейты: центр тайла в гейтах — дубль tileCenter с хардкодом ×3 — теперь + `t*5+2.5`; подходы телепортом ±3→±5 (1 тайл, в радиусе интеракта 7.5); + диапазон камеры катсцены tune пересчитан (цель — тон-дерево [12,10]). + Радиусы света в данных — в юнитах, масштабируются `×UNIT` в движке. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/ambientParticles.ts b/v2/apps/game/src/ambientParticles.ts index 8377616..1cb1b1c 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 = 22; // половина ширины облака мотыльков вокруг камеры (как пепел) +const RADIUS = 30; // половина ширины облака мотыльков вокруг камеры (как пепел) /** Клок тумана: квадрат 2×2 вокселя ЛЕЖИТ на воде (как полупрозрачные * пятна v1 — непрозрачный куб в воздухе читался бы «ящиком»). */ const FOG_TILE: readonly (readonly [number, number])[] = [[0, 0], [1, 0], [0, 1], [1, 1]]; diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index 1500851..798c1c0 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -1,10 +1,11 @@ /** * Области «Пепельных луга» на v2: раскладка тайлов v1 (maps.json, референс) - * → воксельный мир. 1 тайл v1 = 3×3 вокселя (1 юнит = 3 вокселя, как в бою - * демки). Рельеф — плоский (земля y0-1), объекты — штампы генераторов - * движка (деревья/валуны) и локальных генераторов контента (кусты, дома, - * колодец, башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются - * на границе: центр тайла = (tile*UNIT + 1.5) в воксельных координатах. + * → воксельный мир. 1 тайл v1 = 5×5 вокселей (1 юнит = 5 вокселей; герой 11 + * ≈ 2 тайла ростом — дерево/дом заметно выше, пропорции «человек и мир»). + * Рельеф — плоский (земля y0-1), объекты — штампы генераторов движка + * (деревья/валуны) и локальных генераторов контента (кусты, дома, колодец, + * башня). Позиции v1 (NPC/переходы/квесты, в тайлах) конвертируются на + * границе: центр тайла = (tile*UNIT + 2) в воксельных координатах. */ import { VoxelWorld, VoxelGrid, stampModel, @@ -27,8 +28,8 @@ export const AREA_LAYOUTS = mapsData as unknown as Record; -/** Вокселей на тайл v1 (1 юнит = 3 вокселя — общий масштаб демки/боя). */ -export const UNIT = 3; +/** Вокселей на тайл v1 (1 юнит = 5 вокселей — герой 11 ≈ 2 тайла ростом). */ +export const UNIT = 5; /** Точка ног героя в вокселях (верх земли). */ export const GROUND_Y = 2; @@ -59,31 +60,31 @@ /** Детерминированное пятно по координате тайла (чистый хэш, без ГПСЧ). */ const mottle = (tx: number, ty: number, salt = 0) => (tx * 7 + ty * 13 + salt * 31) % 11 < 3; -// ---- Генераторы контента игры (деревья/кусты/валуны в тайл 3×3, дома...) ---- +// ---- Генераторы контента игры (деревья/кусты/валуны в тайл 5×5, дома...) ---- -/** Дерево карты: высокое (заметно выше юнитов — герой 10), footprint тайла - * 3×3 (крона не вылезает на соседей — blocked-семантика v1). +/** Дерево карты: высокое (в ~2 раза выше героя — 11), footprint тайла + * 5×5 (крона не вылезает на соседей — 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(6, 8) : rng.range(5, 7); - const crownH = variant === 2 ? rng.range(5, 7) : rng.range(4, 6); - const g = new VoxelGrid(3, trunkH + crownH + 1, 3); - for (let y = 0; y < trunkH; y++) g.set(1, y, 1, 4); // ствол - const r = 1 + rng.next() * 0.35; // джиттер радиуса фиксируем заранее + 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; // джиттер радиуса фиксируем заранее for (let dy = 0; dy < crownH; dy++) { const y = trunkH - 1 + dy; - for (let dz = -1; dz <= 1; dz++) - for (let dx = -1; dx <= 1; dx++) { + for (let dz = -2; dz <= 2; dz++) + for (let dx = -2; dx <= 2; 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 <= 1; - else ok = d2 <= (dy < crownH / 2 ? 2 : 1); // ель: ярусы сужаются + else if (variant === 1) ok = d2 <= 2; + else ok = d2 <= (dy < crownH / 2 ? 4 : 2); // ель: ярусы сужаются if (!ok) continue; - if (dy > 0 && d2 > 1 && !rng.chance(0.7)) continue; // рваный край + if (dy > 0 && d2 > 2 && !rng.chance(0.7)) continue; // рваный край const light = dy === crownH - 1 || rng.chance(0.25); - g.set(1 + dx, y, 1 + dz, light ? 5 : 3); + g.set(2 + dx, y, 2 + dz, light ? 5 : 3); } } return encodeModel(g, { 3: '#626c4c', 5: '#7d8662', 4: '#5c3b2e' }); @@ -92,40 +93,40 @@ /** Куст карты: низкая крона без ствола, ровно в тайл. */ export function mapBushModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x5a51); - const H = variant === 1 ? 3 : 2; - const g = new VoxelGrid(3, H, 3); + const H = variant === 1 ? 4 : 3; + const g = new VoxelGrid(5, H, 5); for (let y = 0; y < H; y++) - for (let dz = -1; dz <= 1; dz++) - for (let dx = -1; dx <= 1; dx++) { + for (let dz = -2; dz <= 2; dz++) + for (let dx = -2; dx <= 2; dx++) { const d2 = dx * dx + dz * dz; - if (y === 0 ? d2 > 2 : d2 > 1) continue; // низ шире, верх уже + if (y === 0 ? d2 > 5 : d2 > 2) continue; // низ шире, верх уже if (d2 > 0 && !rng.chance(0.8)) continue; - g.set(1 + dx, y, 1 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3); + g.set(2 + dx, y, 2 + dz, y === H - 1 || rng.chance(0.25) ? 5 : 3); } return encodeModel(g, { 3: '#626c4c', 5: '#7d8662' }); } -/** Валун карты: глыба-купол в тайл (не ниже 2 — не плитка), свет на верхушке. */ +/** Валун карты: глыба-купол в тайл (не ниже 3 — не плитка), свет на верхушке. */ export function mapBoulderModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x4b0d); - const H = 2 + Math.min(variant, 1) + (rng.next() < 0.4 ? 1 : 0); // 2..4 - const g = new VoxelGrid(3, H, 3); + const H = 3 + Math.min(variant, 1) + (rng.next() < 0.4 ? 1 : 0); // 3..5 + const g = new VoxelGrid(5, H, 5); for (let y = 0; y < H; y++) { - const w = y === 0 ? 3 : y === H - 1 ? 1 : 2; // купол: шире к низу - const o = (3 - w) >> 1; + const w = y === 0 ? 5 : y === H - 1 ? 1 : 3; // купол: шире к низу + const o = (5 - w) >> 1; for (let dz = 0; dz < w; dz++) for (let dx = 0; dx < w; dx++) { - if (w === 2 && dx === 1 && dz === 1 && rng.chance(0.5)) continue; + if (w === 3 && dx === 1 && dz === 1 && 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 раза выше героя). */ export function houseModel(kind: number, _seed: number): VoxelModel { const tall = kind === T.HOUSE_V3 || kind === T.HOUSE_V4; - const W = 8, H = tall ? 14 : 11, D = 6; + const W = 12, H = tall ? 18 : 14, D = 9; const g = new VoxelGrid(W, H, D); for (let y = 0; y < H - 2; y++) for (let z = 0; z < D; z++) @@ -145,52 +146,52 @@ /** Колодец: каменное кольцо + ворот с брусом (tall 1 юнит). */ export function wellModel(_seed: number): VoxelModel { - const g = new VoxelGrid(3, 4, 3); - for (let z = 0; z < 3; z++) - for (let x = 0; x < 3; x++) { - const edge = x === 0 || z === 0 || x === 2 || z === 2; + 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; if (edge) g.set(x, 0, z, 6); } g.set(0, 1, 0, 4); - g.set(2, 1, 0, 4); - g.set(1, 2, 0, 10); // ворот (металл) - g.set(1, 3, 0, 4); + g.set(4, 1, 0, 4); + g.set(2, 3, 0, 10); // ворот (металл) + g.set(2, 5, 0, 4); return encodeModel(g, { 6: '#5b5b66', 4: '#5c3b2e', 10: '#44454e' }); } /** Башня звона: колонна с площадкой и колоколом (высокая — доминирует в кадре). */ export function towerModel(_seed: number): VoxelModel { - const g = new VoxelGrid(3, 13, 3); - for (let y = 0; y < 10; y++) - for (let z = 0; z < 3; z++) - for (let x = 0; x < 3; x++) g.set(x, y, z, 6); - for (let x = 0; x < 3; x++) - for (let z = 0; z < 3; z++) g.set(x, 10, z, 7); // площадка, свет камня - g.set(1, 11, 1, 10); // колокол (металл) - g.set(1, 12, 1, 4); + 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); return encodeModel(g, { 6: '#5b5b66', 7: '#94949e', 10: '#44454e', 4: '#5c3b2e' }); } /** Старая Машина: тяжёлый блок механизма (prop 2×2 тайла, выше героя). */ export function machineModel(_seed: number): VoxelModel { - const g = new VoxelGrid(8, 12, 7); - for (let y = 0; y < 8; y++) - for (let z = 0; z < 7; z++) - for (let x = 0; x < 8; x++) + 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++) g.set(x, y, z, (x + z + y) % 4 === 0 ? 10 : 6); - for (let z = 2; z < 5; z++) - for (let x = 1; x < 7; x++) g.set(x, 8, z, 8); - for (let z = 2; z < 5; z++) g.set(4, 9, z, 10); - g.set(4, 10, 3, 9); // тёплая искра в жерле (жизнь!) + 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); // тёплая искра в жерле (жизнь!) 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(4, 3, 3); - for (let z = 0; z < 3; z++) - for (let x = 0; x < 4; x++) { - const rim = x === 0 || x === 3 || z === 1; + 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; if (rim) g.set(x, 0, z, 8); if (rim && (x + z) % 2 === 0) g.set(x, 1, z, 10); } @@ -213,7 +214,7 @@ const [sx, , sz] = m.size; stampModel( world, m, - tx * UNIT + 1 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 1 - ((sz - 1) >> 1), + tx * UNIT + 2 - ((sx - 1) >> 1), GROUND_Y, ty * UNIT + 2 - ((sz - 1) >> 1), ); } @@ -222,7 +223,7 @@ * пятна — хэш координат, штампы — генераторы с сидом от координат. */ export function buildAreaWorld(l: AreaLayout): VoxelWorld { - const world = new VoxelWorld(20); // высота чанка 20 (дерево до 15 + запас) + const world = new VoxelWorld(26); // высота чанка 26 (дерево до 23 + запас) const tiles = rleDecode(l.tiles, l.width * l.height); // 1) земля (все тайлы) — потом декор, потом штампы (объекты поверх) for (let ty = 0; ty < l.height; ty++) @@ -262,7 +263,7 @@ 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 slot = d.id === DECOR.TUFT ? 5 : d.id === DECOR.STONE ? 7 : 2; - world.set(d.x * UNIT + 1 + ox, 1, d.y * UNIT + 1 + oz, slot); + world.set(d.x * UNIT + 2 + ox, 1, d.y * UNIT + 2 + oz, slot); } /** RLE-пары → плоский массив (недостающие — пепел). */ @@ -308,19 +309,20 @@ /** Рельсы: металлическая нить по земле (полоса тайлов даёт линию). */ function stampRail(world: VoxelWorld, tx: number, ty: number): void { - for (let d = 1; d < UNIT; d++) world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 1, RAIL); + for (let d = 0; d < UNIT; d++) world.set(tx * UNIT + d, GROUND_Y, ty * UNIT + 2, RAIL); } /** Колокольчик: цветок на пепле (v1 harvest-тайл). */ function stampBellflower(world: VoxelWorld, tx: number, ty: number): void { - world.set(tx * UNIT + 1, GROUND_Y, ty * UNIT + 1, FLOWER); + world.set(tx * UNIT + 2, GROUND_Y, ty * UNIT + 2, FLOWER); } -/** Стена интерьера: колонка камня высотой 4 над полом (v1 tall 1 юнит). */ +/** Стена интерьера: колонка камня высотой 8 над полом (обзор сверху сохраняем: + * ниже героя — комнаты читаются, но стена уже не по колено). */ 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 + 4; y++) + for (let y = GROUND_Y; y < GROUND_Y + 8; 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 2f9ddc7..c425da0 100644 --- a/v2/apps/game/src/ash.ts +++ b/v2/apps/game/src/ash.ts @@ -8,9 +8,9 @@ import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; /** Параметры пепла (масштаб мира v2: радиус в вокселях). */ -const COUNT = 36; // хлопков в облаке (v1 ~40 частиц) -const RADIUS = 22; // половина ширины облака вокруг камеры -const TOP = 14; // высота верхнего слоя пепла над землёй +const COUNT = 48; // хлопков в облаке (v1 ~40 частиц) +const RADIUS = 30; // половина ширины облака вокруг камеры (кадр 40×22.5 вокс) +const TOP = 22; // высота верхнего слоя пепла над землёй const SPEED = 0.35; // дрейф по ветру (вокс/сек) /** Цвет пепла (палитра сцены: P3 — светлый пепел). */ diff --git a/v2/apps/game/src/deer.ts b/v2/apps/game/src/deer.ts index 2e57ed2..81dafcc 100644 --- a/v2/apps/game/src/deer.ts +++ b/v2/apps/game/src/deer.ts @@ -32,8 +32,8 @@ } /** - * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~7 вокселей - * (герой — 10). step — фаза шага (0/1), поднимает диагональные пары ног; + * Олень в мировых координатах: ноги на земле (GROUND_Y), высота ~9 вокселей + * (герой — 11). step — фаза шага (0/1), поднимает диагональные пары ног; * dir — направление взгляда (см. rotXZ). */ export function deerVoxels(cx: number, cz: number, step: boolean, dir = 0): DeerVoxel[] { @@ -44,26 +44,25 @@ }; // Ноги: диагональные пары (перед-лево + зад-право) поднимаются на шаге. const lift = step ? 1 : 0; - for (const [lx, lz] of [[-1, -1], [2, 1], [-1, 1], [2, -1]] as const) { - const up = (lx === -1) === (lz === -1) ? lift : 0; // диагональ шагает - put(lx, GROUND_Y + up, lz, DARK); - put(lx, GROUND_Y + 1 + up, lz, DARK); + 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); } - // Туловище: 5×2×3, спина светлее. - for (let dx = -2; dx <= 2; dx++) + // Туловище: 7×2×3, спина светлее. + for (let dx = -3; dx <= 3; dx++) for (let dz = -1; dz <= 1; dz++) { - put(dx, GROUND_Y + 2, dz, dx % 2 === 0 ? BODY : DARK); - put(dx, GROUND_Y + 3, dz, LIGHT); + put(dx, GROUND_Y + 3, dz, dx % 2 === 0 ? BODY : DARK); + put(dx, GROUND_Y + 4, dz, LIGHT); } // Шея и голова: вперед-вверх, нос темный, уши-рожки. - put(3, GROUND_Y + 3, 0, BODY); - put(3, GROUND_Y + 4, 0, BODY); + put(4, GROUND_Y + 4, 0, BODY); put(4, GROUND_Y + 5, 0, BODY); - put(5, GROUND_Y + 5, 0, DARK); // нос - put(3, GROUND_Y + 6, 0, LIGHT); - put(4, GROUND_Y + 6, -1, ANTLER); - put(4, GROUND_Y + 6, 1, ANTLER); + 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(-3, GROUND_Y + 3, 0, LIGHT); + put(-4, GROUND_Y + 4, 0, LIGHT); return out; } \ No newline at end of file diff --git a/v2/apps/game/src/fauna.ts b/v2/apps/game/src/fauna.ts index 84d217e..2a198e0 100644 --- a/v2/apps/game/src/fauna.ts +++ b/v2/apps/game/src/fauna.ts @@ -11,9 +11,9 @@ /** Радиус слуха на звон (v1: 7 юнитов = 7 тайлов → воксели). */ const HEAR_RADIUS = 7 * UNIT; -/** Скорость брожения / подхода (v1: тайлов/сек → вокселей/сек). */ -const WANDER_SPEED = 0.35 * UNIT; -const APPROACH_SPEED = 0.7 * UNIT; +/** Скорость брожения / подхода (v1: тайлов/сек × UNIT + фидбек «живее»). */ +const WANDER_SPEED = 0.45 * UNIT; +const APPROACH_SPEED = 1.0 * UNIT; /** Пауза между шагами брожения (сек), как в v1: 1.5 + rng*3. */ const PAUSE_BASE = 1.5; const PAUSE_RANGE = 3; diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 131ee13..aace72a 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -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: 24 }); +const renderer = new VoxelRenderer(canvas, { target: [40, 2, 60], width: 960, height: 540, viewSize: 40 }); // ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ---- const cutsceneActions: Record = { @@ -114,8 +114,8 @@ const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], { size: BODY_SIZE, - speed: 5.25, // v1: 1.75 юнита/с × UNIT 3 (темп референса) - strideLength: 3.2, + speed: 10.5, // v1: 1.75 юнита/с × UNIT 5 + фидбек «быстрее» → 2.1 юнита/с + strideLength: 6.4, // темп шага как в v1 (1.64 цикла/с при 2.1 юнита/с) }); const heroCloud = renderer.addVoxelCloud(hero.visual()); @@ -207,7 +207,7 @@ } // ---- NPC: статичные по области + размещения по флагам (Клинт) ---- -const NPC_RADIUS = 4.5; // радиус подхода (v1: 1.5 юнита; расчёты расстановки NPC v1 держат этот радиус) +const NPC_RADIUS = 7.5; // радиус подхода (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: 3.2, + size: BODY_SIZE, speed: 0, strideLength: 6.4, }); char.body.pos = [c.x, GROUND_Y, c.z]; return { @@ -403,7 +403,7 @@ flower: 3, }; /** Эпсилон ничьей (воксели; v1: 0.05 юнита × UNIT). */ -const PICK_EPS = 0.15; +const PICK_EPS = 0.25; /** Выбор цели «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 = 4.5 вокселя. */ -const INTERACT_RANGE = 4.5; +/** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 7.5 вокселя. */ +const INTERACT_RANGE = 7.5; /** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */ function tick(dt: number): void { diff --git a/v2/apps/game/src/props.ts b/v2/apps/game/src/props.ts index 50f5c17..b5b539f 100644 --- a/v2/apps/game/src/props.ts +++ b/v2/apps/game/src/props.ts @@ -1,7 +1,7 @@ /** * Воксельные пропсы интерактивов (визуал на месте «вьюх» v1): столбы у * троп, моты (тёплая искра — жизнь!), сундук, записка, прилавок, верёвка - * колокола, резонатор. Штамп строго в тайл 3×3 (урок трека 15: вылезать + * колокола, резонатор. Штамп строго в тайл 5×5 (урок трека 15: вылезать * на соседей нельзя). clearProp снимает проп и возвращает землю. */ import type { VoxelWorld } from '@rpg/engine'; @@ -24,20 +24,26 @@ /** Поставить проп интерактива в его тайл (детерминированно, без ГПСЧ). */ export function stampProp(world: VoxelWorld, def: InteractableDef): void { if (!hasProp(def)) return; - const bx = def.tile.x * UNIT + 1; // центр тайла - const bz = def.tile.y * UNIT + 1; + const bx = def.tile.x * UNIT + 2; // центр тайла + const bz = def.tile.y * UNIT + 2; if (def.kind === 'pickup') { - world.set(bx, GROUND_Y, bz, SPARK); // мот: одна тёплая искра + // мот: тёплая искра-столбик (видна издали) + world.set(bx, GROUND_Y, bz, SPARK); + world.set(bx, GROUND_Y + 1, bz, SPARK); return; } if (def.kind === 'signpost') { - world.set(bx, GROUND_Y, bz, WOOD); - world.set(bx, GROUND_Y + 1, bz, WOOD_L); // доска + world.set(bx, GROUND_Y, bz, WOOD); // столб + world.set(bx, GROUND_Y + 1, bz, WOOD); + world.set(bx, GROUND_Y + 2, 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); return; } @@ -48,14 +54,17 @@ if (def.id === 'bell_rope') { // верёвка у башни: столбик с деревянной ручкой world.set(bx, GROUND_Y, bz, METAL); - world.set(bx, GROUND_Y + 1, bz, WOOD); + world.set(bx, GROUND_Y + 1, bz, METAL); + world.set(bx, GROUND_Y + 2, bz, WOOD); return; } if (def.id === 'clint_resonator') { // резонатор: медный короб на стальной раме world.set(bx, GROUND_Y, bz, METAL); - world.set(bx, GROUND_Y + 1, bz, WOOD_L); 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); return; } } @@ -65,6 +74,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 + 1; y++) + for (let y = GROUND_Y; y <= GROUND_Y + 2; 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 e8299ec..1f14d02 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 * 3 + 1.5, z: t.y * 3 + 1.5 }); +const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.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 + 3, t2.z); + await client.call('heroTeleport', t2.x + 5, 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 + 3); + await client.call('heroTeleport', mach.x, mach.z + 5); 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 + 3, c.z); + await client.call('heroTeleport', c.x + 5, 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 + 3, c1.z); + await client.call('heroTeleport', c1.x + 5, 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 + 3, rez.z); + await client.call('heroTeleport', rez.x + 5, 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] > 40 && hi.camTarget[0] < 72 + cs.active && cs.step === 2 && hi.camTarget[0] > 63 && hi.camTarget[0] < 127 && 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 + 3), { toasts: di.toasts, cam: hiEnd.camTarget }); + && Math.round(hiEnd.camTarget[0]) === Math.round(rez.x + 5), { 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 + 3); + await client.call('heroTeleport', mach.x, mach.z + 5); 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 + 3, d0.z); + await client.call('startleFauna', d0.x + 5, 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 + 3)) < 0.1 + fi.deer[0].state === 'idle' && Math.abs(fi.deer[0].x - (d0.x + 5)) < 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 b7afb13..9991ed3 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 + 1.5). */ -const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 }); +/** Воксельный центр тайла (как tileCenter в игре: tile*UNIT + 2.5). */ +const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 }); function countDiff(a, b) { let n = 0; @@ -140,7 +140,7 @@ { tree: sTree, water: sWater, path: sPath }); // Тело зажимается гранью воды (вода — сплошной слой на уровне ног) - await client.call('heroTeleport', water.x - 3.6, water.z); + await client.call('heroTeleport', water.x - 5, 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 - 3, trig.z); + await client.call('heroTeleport', trig.x - 5, 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 + 3, pTrig.z); + await client.call('heroTeleport', pTrig.x + 5, 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 - 3, hatch.z); + await client.call('heroTeleport', hatch.x - 5, 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 + 3, door.z); + await client.call('heroTeleport', door.x + 5, 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 + 3, door.z); + await client.call('heroTeleport', door.x + 5, 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 dd2a7e7..c2459f5 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*3 + 1.5). */ -const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 }); -/** Воксель цветка/мота тайла ([tx*3+1, GROUND_Y, ty*3+1]). */ -const spotVoxel = (t) => [t.x * 3 + 1, 2, t.y * 3 + 1]; +/** Центр тайла v1 в вокселях (tile*5 + 2.5). */ +const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.5 }); +/** Воксель цветка/мота тайла ([tx*5+2, GROUND_Y, ty*5+2]). */ +const spotVoxel = (t) => [t.x * 5 + 2, 2, t.y * 5 + 2]; /** Прошагать с зажатым стиком и вернуть пройденную дистанцию по 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 - 3, signC.z); + await client.call('heroTeleport', signC.x - 5, 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 + 3, moteC.z); + await client.call('heroTeleport', moteC.x + 5, 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 + 3, pSignC.z); + await client.call('heroTeleport', pSignC.x + 5, 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 + 3, flC.z); + await client.call('heroTeleport', flC.x + 5, flC.z); await client.call('stepTicks', 3); const ii3 = await client.call('interactInfo'); check('цветок: кандидат «flower» с тайлом и подсказкой', @@ -139,16 +139,16 @@ await client.call('gotoArea', 'house_elder'); await client.call('stepTicks', 5); const chestC = center({ x: 3, y: 3 }); - await client.call('heroTeleport', chestC.x + 3, chestC.z); + await client.call('heroTeleport', chestC.x + 5, chestC.z); await client.call('stepTicks', 3); await client.call('interact'); const chest = await client.call('dialogueInfo'); check('сундук: полотно в сумке, проп убран', chest.inv.some((s) => s.id === 'cloth') - && (await client.call('voxAt', chestC.x - 0.5, 2, chestC.z - 0.5)) === 0, + && (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 + 3, noteC.z); + await client.call('heroTeleport', noteC.x + 5, 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 + 3, hearthC.z + 1.5); + await client.call('heroTeleport', hearthC.x + 5, hearthC.z + 2.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 + 3, ropeC.z); + await client.call('heroTeleport', ropeC.x + 5, 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 + 3); + await client.call('heroTeleport', machC.x, machC.z + 5); 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 + 3, hearthC.z + 1.5); + await client.call('heroTeleport', hearthC.x + 5, hearthC.z + 2.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 0ab1597..280a5e3 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*3 + 1.5). */ -const center = (t) => ({ x: t.x * 3 + 1.5, z: t.y * 3 + 1.5 }); +/** Воксельный центр тайла v1 (как tileCenter: tile*5 + 2.5). */ +const center = (t) => ({ x: t.x * 5 + 2.5, z: t.y * 5 + 2.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 - 3, elderC.z); + await client.call('heroTeleport', elderC.x - 5, 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 - 3, traderC.z); + await client.call('heroTeleport', traderC.x - 5, 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 - 3, clintC.z); + await client.call('heroTeleport', clintC.x - 5, 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