diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 4ffe73f..3d5de58 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -113,10 +113,10 @@ - `src/models/generators.ts` — процедурные генераторы: `treeModel` (варианты 0 круглая / 1 колонна / 2 ель), `boulderModel` (union сфер, свет на верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; - снаряжение (трек 32): `backpackModel` (6×7×2, 84 вокс — пряжка - ПЕРЕКРАШИвает воксели кожи, не добавляет), `torchModel` (1×6×1, 6 вокс), - `flashlightModel` (2×2×5 вдоль +Z, 20 вокс — трек 33: фонарик героя, - линза впереди торчит за фасад руки); + снаряжение (трек 32, размеры трека 35): `backpackModel` (4×5×2, 40 вокс — + пряжка ПЕРЕКРАШИвает воксели кожи, не добавляет), `torchModel` (1×4×1, + 4 вокс), `flashlightModel` (2×2×4 вдоль +Z, 16 вокс — трек 33: фонарик + героя, линза впереди торчит за фасад руки); палитра `GearPalette {leather?, strap?, metal?, wood?, flame?, flameCore?}`; - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом @@ -131,8 +131,9 @@ слотов модели на палитру сцены (точный hex или ближайший RGB, при равенстве — меньший слот); нужен после импорта .vox; - `src/models/mannequin.ts` — `mannequin(palette?)`: эталонная модель - 10×16×4 «путник» (~1.6 тайла, 164 вокс; трек 26 — меньше вокселей, - деталь на текстурах) с ригой (root → legL/legR/armL/armR): сапоги + 8×12×4 «путник» (~1.2 тайла, 134 вокс; трек 26 — деталь на текстурах, + трек 35 — размер под пропорции мира) с ригой (root → legL/legR/armL/armR): + сапоги с мысками, юбка плаща шире, пояс, плечи, кисти, шея, голова; лицо — текстурой (арт 8×8 текселей: бахрома волос, глаза 2 текселя, рот — тайли 2×2 на 16 вокселях фасада z3 головы), волосы — макушка и @@ -1317,6 +1318,56 @@ - Визуально — одноразовая проба `tools/probe-visual.mjs` (ночь с лучом, край карты днём, крупные деревья) — НЕ коммитится. +## Трек 35 (2026-09-12): утопленные озёра, деревья крупнее, манекен 8×12×4, +## теневой фрустум по кадру, частицы в системе освещения + +Пять пунктов фидбека (задачи #201-205): +- **Озёра утоплены** (`apps/game/src/areas.ts`): вода больше не слой НА земле — + в водных тайлах яма: y=1 пуст, вода на y=0 (WATER/WATER_LIGHT), поверхность + воды на грань ниже земли. **Непроходимость — через `physicsWorld` в main.ts**: + обёртка над VoxelWorld, `get(x,y,z)` возвращает стену, если `y===GROUND_Y`, + под ней на y=1 пусто, а на y=0 что-то есть (невидимый коллайдер над ямами) — + рендер и коллизия разделены. Герой/NPC шагают `hero.step(physicsWorld, dt)`, + safeEntry смотрит `collides(physicsWorld, ...)`. `tileBlocked`/`solidAt` + проверяют яму до проверки y. Abyss-плоскость опущена на y=−0.1 (иначе видна + в ямах над водой). **Декор на воде не штампуется** (иначе зелёные острова в + ямах). Водный шиммер поднят на y=1.03. +- **Деревья ещё крупнее** (`mapTreeModel`): ствол v0 25–31 / v1 32–38 / + v2 27–33 (в 1.6–2.4 раза выше героя), крона 17–24 (v2 ель самая высокая), + радиус 4.6+rng·1.2; чанк 64 (штамп с y=2 — модель до 62, максимум дерева 60). +- **Персонажи меньше — детализация на текстурах** (манекен 14×24×8 трека 25 + → 10×16×4 трека 26 → **8×12×4, ~134 вокс**): лицо живёт тайлями 8×8 на + 16 вокселях фасада (тексель = слот палитры), поэтому размер сетки детализацию + не теряет. Скины: hem/clasp/sole (19 тайлей). Пивоты: root [4,0,2.5], + ноги/руки по бокам; BODY_SIZE [5,12,5], stride 7.2 (скорость 21 сохранена — + каденс выше), flashlight origin GROUND_Y+5. Снаряжение уменьшено + пропорционально: рюкзак 4×5×2=40, факел 1×4×1=4, фонарик 2×2×4=16 (60 вокс), + точки крепления [2,4,-1]/[7.5,3,2]/[-0.5,3,0]. Гейты panels/gear пины + обновлены (134/5/19; 60/40). +- **Тени: граница появления — баг фрустума** (`voxelRenderer.ts`): радиус + теневой камеры был `viewSize·1.2` (96), а видимая полуширина кадра — + `viewSize·aspect` (80·16/9 ≈ 142) — за краем фрустума тени включались + резкой линией посреди кадра. Теперь **динамический фрустум в setSun**: + `visible = viewSize·max(aspect,1)`, `r = visible + SHADOW_CASTER_H/tan(elev)` + (высота кастера 64 = высота чанка), far с запасом; mapSize 1024→2048 (плотность + при выросшем радиусе); пересчёт при `cam.right !== r`. viewAspect — поле + рендера. Проверено кадрами утра/вечера — линии нет. +- **Частицы меньше и в системе освещения**: + - движок (`render/particles.ts`): uniform `uTint` (vec3) в POINT_FRAG — + `setTint(color)` на ParticleLayer (hex вручную в компоненты, без + colorspace-конверсий; при росте ёмкости редирект setTint вместе с + update/dispose); + - игра (`lighting.ts`): `PARTICLE_NIGHT = 0x40465c`, `pTint = lerpColor( + 0xffffff, PARTICLE_NIGHT, k)` каждый тик → dep `particleTint` → пепел/ + airFx/шиммер затемняются ночью как мир (снапшот `particleTint`, + atmosphere-гейт: 16777215 день → 4212316 ночь). **Мотыльки самосветятся** — + `AmbientParticles.setTint` тинтит только дымку, не мотыльков; + - размеры: пепел 1→0.7, листья 1.4→0.9, пыль 0.7→0.45, дымка 2→1.4, + мотыльки 1→0.7. +- Гейты: areas ✓ (detDiff 0/gap 7.5/вода), atmosphere ✓, panels ✓, gear ✓; + `check:fast` — 327 тестов ✓. Визуально — зум-кропы героя/пруда, ночь с + фонариком, утро/вечер без линии теней. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/airFx.ts b/v2/apps/game/src/airFx.ts index 31327f3..713d6f0 100644 --- a/v2/apps/game/src/airFx.ts +++ b/v2/apps/game/src/airFx.ts @@ -55,7 +55,7 @@ pos: [ax + Math.sin(t * 1.1 + p) * 0.8, y, az + Math.cos(t * 0.9 + p) * 0.8], color: LEAVES_COLORS[i % 7 === 6 ? 2 : i % 2]!, alpha: edgeAlpha(win, MARGIN, ax, az), - size: 1.4, + size: 0.9, // лист мельче (фидбек — частицы крупноваты) }); } } else { @@ -70,13 +70,18 @@ pos: [ax, y, az], color: DUST_COLOR, alpha: DUST_ALPHA * edgeAlpha(win, MARGIN, ax, az), - size: 0.7, + size: 0.45, // пылинка мельче (фидбек) }); } } this.layer.update(points); } + /** Тинт освещения (ночь гасит листья/пыль, как сцену — мост lighting). */ + setTint(color: number): void { + this.layer.setTint(color); + } + /** Скрыть слой (мост setAsh / область без профиля). */ hide(): void { this.layer.update([]); diff --git a/v2/apps/game/src/ambientParticles.ts b/v2/apps/game/src/ambientParticles.ts index 05a472e..c7a3d69 100644 --- a/v2/apps/game/src/ambientParticles.ts +++ b/v2/apps/game/src/ambientParticles.ts @@ -19,8 +19,10 @@ const FOG_COUNT = 22; const FOG_COLOR = '#a7b3bd'; // светлее воды — клочья читаются туманом const FOG_ALPHA = 0.55; // полупрозрачные пятна — как в v1 -const FOG_Y = 3.05; // чуть над поверхностью воды (вода — слой y=2, верх y=3) +const FOG_Y = 1.05; // чуть над поверхностью воды (вода утоплена: слой y=0, верх y=1) +const FOG_SIZE = 1.4; // клок мельче (фидбек — частицы крупноваты) const MARGIN = 16; // перекрытие окна мотыльков за краем кадра +const MOTH_SIZE = 0.7; // мотылёк мельче (фидбек) export class AmbientParticles { private readonly layer: ParticleLayer; @@ -61,6 +63,7 @@ pos: [x, y, z], color: MOTH_COLORS[i % MOTH_COLORS.length]!, alpha: edgeAlpha(win, MARGIN, x, z), + size: MOTH_SIZE, }); } } else { @@ -74,13 +77,19 @@ pos: [a.x + Math.sin(t * 0.05 + p) * 2.5, FOG_Y, a.z + Math.cos(t * 0.04 + p * 1.3) * 2.5], color: FOG_COLOR, alpha: FOG_ALPHA, - size: 2, // клок 2×2, лежит на воде + size: FOG_SIZE, // клок лежит на воде }); } } this.layer.update(points); } + /** Тинт освещения: клочья дымки гаснут ночью вместе со сценой; мотыльки + * самосветятся — тинт им не ставится (вызывается только для fog). */ + setTint(color: number): void { + if (this.kind === 'fog') this.layer.setTint(color); + } + /** Скрыть слой (мост setAsh / смена профиля). */ hide(): void { this.layer.update([]); diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index adfe720..2092935 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -71,14 +71,16 @@ * Варианты: 0 круглая крона, 1 узкая колонна, 2 ель с ярусами. */ export function mapTreeModel(variant: Variant, seed: number): VoxelModel { const rng = new Rng(seed ^ 0x7ee1); - const trunkH = variant === 1 ? rng.range(28, 34) : variant === 2 ? rng.range(24, 30) : rng.range(22, 28); - const crownH = variant === 2 ? rng.range(17, 22) : rng.range(15, 19); + // деревья крупнее (фидбек): стволы 25–38, кроны 17–24; потолок — чанк + // 64 (штамп на y=2 → модель до 62 вокс) + const trunkH = variant === 1 ? rng.range(32, 38) : variant === 2 ? rng.range(27, 33) : rng.range(25, 31); + const crownH = variant === 2 ? rng.range(19, 24) : rng.range(17, 21); const g = new VoxelGrid(12, trunkH + crownH + 1, 12); for (let y = 0; y < trunkH; y++) { // ствол 2×2 — на этом масштабе 1×1 — щепка g.set(5, y, 5, 4); g.set(6, y, 5, 4); g.set(5, y, 6, 4); g.set(6, y, 6, 4); } - const r = 4.2 + rng.next() * 1.1; // джиттер радиуса фиксируем заранее + const r = 4.6 + rng.next() * 1.2; // джиттер радиуса фиксируем заранее for (let dy = 0; dy < crownH; dy++) { const y = trunkH - 1 + dy; const t = dy / crownH; // 0..1 по высоте кроны @@ -90,7 +92,7 @@ const rdy = r * (1.15 - t * 0.9); // низ вздут, верх сходит на нет ok = d2 <= rdy * rdy + 1; } else if (variant === 1) { - ok = d2 <= (t > 0.6 ? 5 : 9); // колонна: к верху уже + ok = d2 <= (t > 0.6 ? 6 : 11); // колонна: к верху уже } else { // ель: ярусы — каждый третий слой шире (ступень), выше уже ok = d2 <= (dy % 3 === 0 ? 24 : t < 0.5 ? 13 : 6); @@ -537,7 +539,7 @@ */ export function buildAreaWorld(l: AreaLayout): VoxelWorld { registerWorldKinds(); - const world = new VoxelWorld(64); // высота чанка 64 (дерево до 53, башня 47 + запас) + const world = new VoxelWorld(64); // высота чанка 64 (дерево до 60, башня 47 + запас) const tiles = rleDecode(l.tiles, l.width * l.height); // 1) земля (все тайлы) — потом декор, потом штампы (объекты поверх) for (let ty = 0; ty < l.height; ty++) @@ -549,15 +551,21 @@ world.set(x, 0, z, 2); // низ земли — тень травы world.set(x, 1, z, groundFor(t, tx, ty, dx, dz)); if (t === T.WATER) { - // вода — на воксель выше земли (v1: непроходима); - // тело героя стоит на y=2 и упирается в этот слой - world.set(x, 1, z, WATER); - world.set(x, 2, z, groundFor(t, tx, ty, dx, dz)); + // озеро утоплено (фидбек): яма на месте верхнего слоя + // земли, вода лежит на дне — поверхность воды на грань + // ниже земли. Непроходимость даёт физическая обёртка + // мира (main.ts): над ямой невидимый коллайдер на y=2. + world.set(x, 1, z, 0); + world.set(x, 0, z, groundFor(t, tx, ty, dx, dz)); } } } - // 1б) декор v1 (мелкие объекты на земле, уровень y=1 — ходьбе не мешает) - for (const d of l.decor) stampDecor(world, d); + // 1б) декор v1 (мелкие объекты на земле, уровень y=1 — ходьбе не мешает); + // на воде кустиков нет — в утопленном озере они выглядят островами + for (const d of l.decor) { + if ((tiles[d.y * l.width + d.x] ?? T.ASH) === T.WATER) continue; + stampDecor(world, d); + } // 2) штампы объектов for (let ty = 0; ty < l.height; ty++) for (let tx = 0; tx < l.width; tx++) { diff --git a/v2/apps/game/src/ash.ts b/v2/apps/game/src/ash.ts index b41b83d..e739c57 100644 --- a/v2/apps/game/src/ash.ts +++ b/v2/apps/game/src/ash.ts @@ -16,6 +16,7 @@ const TOP = 44; // высота верхнего слоя пепла над землёй const SPEED = 1.2; // дрейф по ветру (юнит/сек; v1: 2..7 px/s при 32 px/юнит) const MARGIN = 16; // перекрытие за краем кадра +const SIZE = 0.7; // хлопок мельче (фидбек — частицы крупноваты) /** Цвет пепла (палитра сцены: P3 — светлый пепел). */ const ASH_COLOR = '#3e3e48'; @@ -30,7 +31,7 @@ /** Поза облака: время цикла (окно — из цели камеры рендерера). */ update(t: number): void { const win = particleWindow(this.renderer, 0, TOP, MARGIN); - const points: { pos: [number, number, number]; color: string; alpha: number }[] = []; + const points: { pos: [number, number, number]; color: string; alpha: number; size?: number }[] = []; for (let i = 0; i < COUNT; i++) { // сид хлопка — от индекса; фазы кружения разнесены золотым сечением const p = i * 2.399; @@ -46,11 +47,16 @@ // вертикаль: оседание 0.5..1.1 юнит/с (v1: 1..4 px/s) const fall = ((t * (0.5 + (i % 5) * 0.15) + (i * 3.7) % TOP) % TOP); const y = TOP - fall; - points.push({ pos: [x, y, z], color: ASH_COLOR, alpha: edgeAlpha(win, MARGIN, x, z) }); + points.push({ pos: [x, y, z], color: ASH_COLOR, alpha: edgeAlpha(win, MARGIN, x, z), size: SIZE }); } this.layer.update(points); } + /** Тинт освещения (ночь гасит пепел, как сцену — мост lighting). */ + setTint(color: number): void { + this.layer.setTint(color); + } + /** Скрыть облако (область без атмосферы). */ hide(): void { this.layer.update([]); diff --git a/v2/apps/game/src/lighting.ts b/v2/apps/game/src/lighting.ts index c0a0290..7ba95b9 100644 --- a/v2/apps/game/src/lighting.ts +++ b/v2/apps/game/src/lighting.ts @@ -5,7 +5,7 @@ * в тёмных областях/ночью. Источники — diff по id каждый тик; мерцание — * детерминированный flickerFactor от времени цикла. */ -import { dayNightAmbient, dayNightFactor, flickerFactor } from '@rpg/engine'; +import { dayNightAmbient, dayNightFactor, flickerFactor, lerpColor } from '@rpg/engine'; import type { PointLightHandle, SpotLightHandle, VoxelRenderer } from '@rpg/engine'; import { UNIT, GROUND_Y, tileCenter } from './areas'; import type { LightingDef, LightSourceDef } from './data/lighting'; @@ -36,6 +36,14 @@ /** Порог ночи для ночных источников и лампы (≈ 19:00–07:00). */ const NIGHT_SOURCE_FACTOR = 0.5; +/** + * Тинт частиц ночью (0xRRGGBB-множитель): слои частиц движка не освещаются + * — без тинта пепел/листья/дымка светятся в полную яркость и ночью («вне + * системы освещения», фидбек). Днём — белый (без множителя), к ночи — + * холодная синева в четверть яркости, как у сцены. + */ +const PARTICLE_NIGHT = 0x40465c; + /** Считается ли область тёмной для лампы (та же эвристика, что v1). */ function isDarkAmbient(ambient: number | undefined): boolean { if (ambient === undefined) return false; @@ -74,6 +82,8 @@ flashlight?: FlashlightDef | null; /** Куда смотрит герой (yaw, рад; 0 — +Z) — направление луча. */ heroFacing: () => number; + /** Тинт частиц слоям (ночь гасит пепел/листья/дымку/блики вместе со сценой). */ + particleTint?: (color: number) => void; } /** Снапшот для моста: фактор ночи, путь солнца + активные источники. */ @@ -81,6 +91,8 @@ nightFactor: number; /** Путь солнца по дню 0..1 (тени следуют за временем суток). */ sunK: number; + /** Тинт частиц этого тика (день — белый 0xffffff, к ночи темнеет). */ + particleTint: number; sources: { id: string; x: number; y: number; intensity: number }[]; /** Фонарик: горит ли и с какой яркостью (пост-мерцание). */ flashlight: { on: boolean; intensity: number }; @@ -129,6 +141,9 @@ ? dayNightAmbient(light.ambient, light.nightAmbient, k, light.duskColor) : light?.ambient ?? 0x94949e; this.deps.renderer.setNight(k, tint); + // частицы — в системе освещения: ночь гасит слои вместе со сценой + const pTint = lerpColor(0xffffff, PARTICLE_NIGHT, k); + this.deps.particleTint?.(pTint); // Солнце едет по часам (6:00 восход → 12:00 зенит → 18:00 закат): // тени меняют направление и длину в течение дня (setSun сам гасит // свет ночью — до и после окна клампимся в [0..1]). @@ -190,7 +205,7 @@ } const hero = this.deps.heroPos(); const fx = Math.sin(this.deps.heroFacing()), fz = Math.cos(this.deps.heroFacing()); - const origin: [number, number, number] = [hero.x + fx * 3, GROUND_Y + 7, hero.z + fz * 3]; + const origin: [number, number, number] = [hero.x + fx * 3, GROUND_Y + 5, hero.z + fz * 3]; const hit: [number, number, number] = [hero.x + fx * fl.distance, GROUND_Y, hero.z + fz * fl.distance]; const flicker = flickerFactor(this.deps.timeSeconds(), seedOf('flashlight'), 0.06, 0.9); this.spotIntensity = fl.intensity * flicker; @@ -212,6 +227,7 @@ return { nightFactor: this.nightFactor, sunK: Math.round(this.sunK() * 1000) / 1000, + particleTint: lerpColor(0xffffff, PARTICLE_NIGHT, this.nightFactor), flashlight: { on: this.spot !== null, intensity: Math.round(this.spotIntensity * 1000) / 1000 }, sources: [...this.handles.entries()].map(([key, entry]) => { const [id, at] = key.split('@'); diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index a2ca551..b119c66 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -79,13 +79,12 @@ const renderer = new VoxelRenderer(canvas, { target: [80, 2, 120], width: 960, height: 540, viewSize: 80 }); // Тьма за пределами карты: неосвещаемая плоскость под миром — область // «парит во тьме» в любое время суток (MeshBasicMaterial не берёт свет). -// На воксель ниже верха земли (y=2), чтобы не спорить с землёй по z. const abyss = new THREE.Mesh( new THREE.PlaneGeometry(20000, 20000), new THREE.MeshBasicMaterial({ color: 0x050507 }), ); abyss.rotation.x = -Math.PI / 2; -abyss.position.y = GROUND_Y - 0.1; +abyss.position.y = -0.1; // под миром целиком: ямы воды открыты сверху renderer.scene.add(abyss); // ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ---- @@ -106,6 +105,18 @@ let world = new VoxelWorld(20); const chunkMeshes = new Map(); +/** + * Физический мир поверх воксельного: ямы (утопленная вода) непроходимы. + * Рендер видит воду на дне ямы (y=0), а коллизия — как раньше: над ямой + * невидимый слой на уровне земли, тело героя упирается в грань воды. + */ +const physicsWorld = { + get(x: number, y: number, z: number): number { + if (y === GROUND_Y && world.get(x, 1, z) === 0 && world.get(x, 0, z) !== 0) return 1; // яма — стена + return world.get(x, y, z); + }, +}; + // ---- Слой тайлей мира: общий атлас реестра worldTex ---- // Слоты тайлей моделей локальны; при штамповке opts.texRemap (areas.ts) // переводит их в глобальные id реестра. Материалы чанков сэмплируют ОДИН @@ -157,12 +168,13 @@ arms: '#5f4a38', boots: '#3a3532', belt: '#8a6a3a', }); const WALK = walkClip({ duration: 0.8, legSwing: 0.55, armSwing: 0.4, bob: 0.4, sway: 0.25 }); -const BODY_SIZE: [number, number, number] = [5, 16, 5]; +// герой меньше (фидбек): манекен 8×12×4 — коллизия уже, но той же ширины +const BODY_SIZE: [number, number, number] = [5, 12, 5]; const hero = new Character(MAN_RIG, MAN, WALK, [80, 120], { size: BODY_SIZE, speed: 21, // v1: 1.75 юнита/с × UNIT 10 + фидбек «быстрее» → 2.1 юнита/с - strideLength: 9.6, // темп v1 при ногах 5 вокс: 2.19 цикла/с (трек 24) + strideLength: 7.2, // шаг короче (ноги 4 вместо 5) — каденс выше, темп тот же }); const MAN_VOXELS = hero.visual().length; // воксели тела без снаряжения @@ -175,17 +187,17 @@ // ---- Снаряжение героя (трек 32, демо механизма креплений): рюкзак за // спиной на корне (едет с покачиванием шага), факел в правой кисти // (машет с рукой), фонарик в левой (трек 33 — луч даёт addSpotLight). -// Точки — в связочном пространстве манекена 10×16×4: торс x2..7/y6..11 -// (спина z1), кисти рук ≈ [8.5 / 0.5, 5, 2]. ---- +// Точки — в связочном пространстве манекена 8×12×4: торс x1..6/y4..7 +// (спина z1), кисти рук ≈ [7.5 / 0.5, 3, 2]. ---- const GEAR_VARIANTS = { backpack: (): Attachment => ({ - bone: 'root', model: backpackModel(), pos: [2, 5, -1], + bone: 'root', model: backpackModel(), pos: [2, 4, -1], }), torch: (): Attachment => ({ - bone: 'armR', model: torchModel(), pos: [8.5, 5, 2], + bone: 'armR', model: torchModel(), pos: [7.5, 3, 2], }), flashlight: (): Attachment => ({ - bone: 'armL', model: flashlightModel(), pos: [0.5, 5, 0], + bone: 'armL', model: flashlightModel(), pos: [-0.5, 3, 0], }), } as const; export type GearId = keyof typeof GEAR_VARIANTS; @@ -276,7 +288,8 @@ return out; } -/** Воксели поверхности воды области (тайл T_WATER=2 v1): центры кубов слоя. */ +/** Клетки поверхности воды области (тайл T_WATER=2 v1): центры вокселей + * слоя воды на дне ямы (якоря дымки/бликов — высоту слои берут сами). */ function waterSurfaceCells(l: AreaLayout): WaterCell[] { const tiles = rleDecode(l.tiles, l.width * l.height); const out: WaterCell[] = []; @@ -341,7 +354,7 @@ }, }); const char = new Character(rig, model, WALK, [c.x, c.z], { - size: BODY_SIZE, speed: 0, strideLength: 9.6, + size: BODY_SIZE, speed: 0, strideLength: 7.2, }); char.body.pos = [c.x, GROUND_Y, c.z]; return { @@ -412,10 +425,11 @@ /** Глобальный вкл/выкл фауны (мост: гейт кадр-равенства — олени живые). */ let faunaEnabled = true; -/** Непроходимый тайл для фауны: колонка над землёй занята (вода/пропы). */ +/** Непроходимый тайл для фауны: яма (вода) или колонка над землёй занята. */ function tileBlocked(tx: number, ty: number): boolean { const vx = tx * UNIT + 1; const vz = ty * UNIT + 1; + if (world.get(vx, 1, vz) === 0) return true; // яма — утопленная вода for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) { if (world.get(vx, y, vz) !== 0) return true; } @@ -435,12 +449,12 @@ * ищем ближайшую свободную спиралью с шагом 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]; + if (!collides(physicsWorld, [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]; + if (!collides(physicsWorld, [c.x + dx, GROUND_Y, c.z + dz], BODY_SIZE)) return [c.x + dx, c.z + dz]; + if (!collides(physicsWorld, [c.x + dz, GROUND_Y, c.z + dx], BODY_SIZE)) return [c.x + dz, c.z + dx]; } } } @@ -488,6 +502,14 @@ timeSeconds: () => elapsed, lamp: HERO_LAMP, flashlight: FLASHLIGHT, + // частицы — в системе освещения: ночь гасит пепел/листья/дымку/блики + // (мотыльки самосветятся — AmbientParticles тинтит только дымку) + particleTint: (c) => { + if (areaAsh) ash.setTint(c); + airFx?.setTint(c); + ambient?.setTint(c); + shimmer?.setTint(c); + }, }); areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется @@ -1102,7 +1124,7 @@ } else { hero.setMove(wm); } - hero.step(world, dt); + hero.step(physicsWorld, dt); heroCloud.update(hero.visual()); if (followHero) { const b = hero.body.pos; @@ -1255,7 +1277,10 @@ }, /** Снапшот освещения: ночь + активные источники (пост-мерцание). */ lightingInfo(): ReturnType { - return lighting?.snapshot() ?? { nightFactor: 0, sunK: 0, sources: [], flashlight: { on: false, intensity: 0 } }; + return lighting?.snapshot() ?? { + nightFactor: 0, sunK: 0, particleTint: 0xffffff, + sources: [], flashlight: { on: false, intensity: 0 }, + }; }, /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды/воздух (гейт). */ ambientInfo() { @@ -1318,9 +1343,10 @@ const def = areaOf(id); setArea(id, entry ?? def.spawn); }, - /** Сплошная ли колонка НАД землёй (вода/объект) — гейт blocked. */ + /** Сплошная ли колонка (яма-вода / объект над землёй) — гейт blocked. */ solidAt(x: number, z: number): boolean { const fx = Math.floor(x), fz = Math.floor(z); + if (world.get(fx, 1, fz) === 0) return true; // яма — утопленная вода for (let y = GROUND_Y; y <= GROUND_Y + 1; y++) { if (world.get(fx, y, fz) !== 0) return true; } diff --git a/v2/apps/game/src/waterShimmer.ts b/v2/apps/game/src/waterShimmer.ts index 719b390..1ab6e63 100644 --- a/v2/apps/game/src/waterShimmer.ts +++ b/v2/apps/game/src/waterShimmer.ts @@ -15,9 +15,9 @@ const STEP = 128; // каждый N-й воксель поверхности — потенциальный блик (редкие) const GLOW = 0.84; // порог фазы: выше — блик виден (вспышка короткая) -/** Высота поверхности воды (вода — слой y=2, верхняя грань y=3) + сдвиг - * против z-fighting с кубом воды. */ -const Y = 3.03; +/** Высота поверхности воды (вода утоплена — слой y=0, верхняя грань y=1) + * + сдвиг против z-fighting с кубом воды. */ +const Y = 1.03; /** Блик: приглушённая рябь и яркая вспышка (поверх W2 #568590). */ const COLORS = ['#8fb8bf', '#cfe4e6'] as const; const GLOW_ALPHA = 0.9; @@ -53,6 +53,11 @@ this.layer.update(points); } + /** Тинт освещения (блики гаснут ночью вместе со сценой — мост lighting). */ + setTint(color: number): void { + this.layer.setTint(color); + } + /** Скрыть блики (не водная область / мост setAsh). */ hide(): void { this.layer.update([]); diff --git a/v2/apps/game/tools/checks/gear.mjs b/v2/apps/game/tools/checks/gear.mjs index a23a5b2..1f64a63 100644 --- a/v2/apps/game/tools/checks/gear.mjs +++ b/v2/apps/game/tools/checks/gear.mjs @@ -42,10 +42,10 @@ // ---- Снаряжение по умолчанию: рюкзак + факел + фонарик ---- const hi = await client.call('heroInfo'); - // рюкзак 84 (пряжка — перекраска); факел 6; фонарик 2×2×5 = 20 → 110 - check('герой по умолчанию: рюкзак + факел + фонарик (110 вокс)', + // рюкзак 40 (пряжка — перекраска); факел 4; фонарик 2×2×4 = 16 → 60 + check('герой по умолчанию: рюкзак + факел + фонарик (60 вокс)', hi.gear.includes('backpack') && hi.gear.includes('torch') && hi.gear.includes('flashlight') - && hi.gearVoxels === 110, hi.gear); + && hi.gearVoxels === 60, hi.gear); await client.call('stepTicks', 1); const shotOn = await client.screenshot('/tmp/v2_gear_on.png'); @@ -60,10 +60,10 @@ await client.call('heroGear', ['backpack', 'torch', 'flashlight']); await client.call('stepTicks', 1); - // ---- Только рюкзак: 84 вокс (факел — 6, фонарик — 20) ---- + // ---- Только рюкзак: 84 вокс (факел — 4, фонарик — 16) ---- await client.call('heroGear', ['backpack']); const hiB = await client.call('heroInfo'); - check('только рюкзак: 84 вокс', hiB.gearVoxels === 84, hiB.gear); + check('только рюкзак: 40 вокс', hiB.gearVoxels === 40, hiB.gear); await client.call('heroGear', ['backpack', 'torch', 'flashlight']); // ---- Ходьба со снаряжением: герой ходит (механизм не ломает физику) ---- diff --git a/v2/apps/game/tools/checks/panels.mjs b/v2/apps/game/tools/checks/panels.mjs index c9660f0..0ee59ae 100644 --- a/v2/apps/game/tools/checks/panels.mjs +++ b/v2/apps/game/tools/checks/panels.mjs @@ -106,8 +106,8 @@ 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 костей / 19 тайлей (трек 29 — скины деталей)', - st1.voxels === 164 && st1.bones === 5 && st1.tiles === 19, st1); + check('статы манекена: 134 вокс / 5 костей / 19 тайлей (трек 29 — скины деталей)', + st1.voxels === 134 && st1.bones === 5 && st1.tiles === 19, st1); // кадр вьювера рисует модель (зона канваса не пустая) await client.call('stepTicks', 1); diff --git a/v2/packages/engine/src/animation/__tests__/attach.test.ts b/v2/packages/engine/src/animation/__tests__/attach.test.ts index 883aee9..09b6bb8 100644 --- a/v2/packages/engine/src/animation/__tests__/attach.test.ts +++ b/v2/packages/engine/src/animation/__tests__/attach.test.ts @@ -23,7 +23,7 @@ describe('attachVoxels', () => { it('пустой список креплений = число вокселей базы', () => { const base = attachVoxels(man.rig, man.model, IDLE, []); - expect(base.length).toBe(164); // все воксели манекена + expect(base.length).toBe(134); // все воксели манекена }); it('предмет у точки крепления, цвет — из палитры предмета', () => { @@ -32,15 +32,19 @@ const item = v[v.length - 1]!; expect(item.color).toBe('#ff0000'); expect(item.pos).toEqual([2.5, 5.5, -0.5]); // центр вокселя предмета - expect(v.length).toBe(165); + expect(v.length).toBe(135); }); it('предмет на кости руки едет вместе с её поворотом', () => { - const atts = [{ bone: 'armR', model: dot('#00ff00'), pos: [8, 5, 2] as const }]; + // предмет в точности в центре вокселя кисти armR (первый воксель кости) + const atts = [{ bone: 'armR', model: dot('#00ff00'), pos: [7, 3, 2] as const }]; const idle = attachVoxels(man.rig, man.model, IDLE, atts); const swing = attachVoxels(man.rig, man.model, { armR: { rot: [0.55, 0, 0] as const } }, atts); // предмет сместился ровно как воксель кисти (та же аффинна кости) - const armVoxel = (list: typeof idle) => list.find((v) => v.index === 0)!; + const armVoxel = (list: typeof idle) => { + const i = man.rig.binding.findIndex((b) => b === 4); // воксель armR + return list.find((v) => v.index === i)!; + }; const dxItem = idle[idle.length - 1]!.pos[1] - swing[swing.length - 1]!.pos[1]; const dxArm = armVoxel(idle).pos[1] - armVoxel(swing).pos[1]; expect(Math.abs(dxItem - dxArm)).toBeLessThan(1e-9); diff --git a/v2/packages/engine/src/models/__tests__/mannequin.test.ts b/v2/packages/engine/src/models/__tests__/mannequin.test.ts index b1187ed..fb87bea 100644 --- a/v2/packages/engine/src/models/__tests__/mannequin.test.ts +++ b/v2/packages/engine/src/models/__tests__/mannequin.test.ts @@ -13,26 +13,25 @@ expect(validateRig(m.rig, m.model)).toEqual([]); }); - it('состав частей: ноги 6 ×2, руки 6 ×2, корень 140 (торс+голова+детали)', () => { + it('состав частей: ноги 5 ×2, руки 5 ×2, корень 114 (торс+голова+детали)', () => { const { model, rig } = mannequin(); const g = decodeModel(model); - expect(g.count()).toBe(164); // 6×2 ноги + 6×2 руки + 140 корень + expect(g.count()).toBe(134); // 5×2 ноги + 5×2 руки + 114 корень 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([140, 6, 6, 6, 6]); + expect(byBone).toEqual([114, 5, 5, 5, 5]); }); it('лицо — текстурой: тайли 2×2 на фасаде головы, глаза в арте, мыски сапог', () => { const m = mannequin(); const g = decodeModel(m.model); const at = (x: number, y: number, z: number) => g.data[x + g.sx * (y + g.sy * z)]; - expect(at(3, 0, 3)).toBe(2); // мысок сапога к +Z - expect(at(4, 11, 2)).toBe(7); // шея - // все 16 вокселей фасада головы (z3, y12..15, x3..6) несут тайли + expect(at(2, 0, 3)).toBe(2); // мысок сапога к +Z + // все 16 вокселей фасада головы (z3, y8..11, x2..5) несут тайли const texId = (x: number, y: number) => texIdAt(m.model, x + g.sx * (y + g.sy * 3)); - for (let y = 12; y <= 15; y++) - for (let x = 3; x <= 6; x++) expect(texId(x, y)).toBeGreaterThan(0); + for (let y = 8; y <= 11; y++) + for (let x = 2; x <= 5; x++) expect(texId(x, y)).toBeGreaterThan(0); // вне фасада тайлей нет expect(texIdAt(m.model, 0)).toBe(0); // 16 тайлей; тайль глаза (x3, y14 → id 5): нижний ряд арта — волос+глаз diff --git a/v2/packages/engine/src/models/generators.ts b/v2/packages/engine/src/models/generators.ts index 88fd915..c19017b 100644 --- a/v2/packages/engine/src/models/generators.ts +++ b/v2/packages/engine/src/models/generators.ts @@ -118,44 +118,45 @@ }; /** - * Рюкзак: кожаный меш 6×7×2 (клапан темным ремнём, пряжка спереди), + * Рюкзак: кожаный меш 4×5×2 (клапан темным ремнём, пряжка спереди), * угол-origine — нижний левый задний угол; на манекене ставится за торс - * (attach pos ≈ [2, 5, -1] в связочном пространстве). Без сида — дизайн - * фиксирован, детерминирован. + * (attach pos ≈ [2, 4, -1] в связочном пространстве, трек 35). Без сида — + * дизайн фиксирован, детерминирован. */ export function backpackModel(p: GearPalette = {}): VoxelModel { const c = { ...GEAR_DEFAULTS, ...p }; - const g = new VoxelGrid(6, 7, 2); - for (let y = 0; y <= 5; y++) for (let z = 0; z <= 1; z++) for (let x = 0; x <= 5; x++) g.set(x, y, z, 1); - for (let x = 0; x <= 5; x++) for (let z = 0; z <= 1; z++) g.set(x, 6, z, 2); // клапан - g.set(2, 4, 1, 3); // пряжка клапана спереди - g.set(3, 4, 1, 3); + // 4×5×2 — под манекен 8×12 (фидбек: персонажи меньше, снаряжение с ними) + const g = new VoxelGrid(4, 5, 2); + for (let y = 0; y <= 3; y++) for (let z = 0; z <= 1; z++) for (let x = 0; x <= 3; x++) g.set(x, y, z, 1); + for (let x = 0; x <= 3; x++) for (let z = 0; z <= 1; z++) g.set(x, 4, z, 2); // клапан + g.set(1, 3, 1, 3); // пряжка клапана спереди + g.set(2, 3, 1, 3); return encodeModel(g, { 1: c.leather!, 2: c.strap!, 3: c.metal! }); } -/** Факел: рукоять 1×6×1 (обмотка у головки), пламя с ярким ядром; угол — низ рукояти. */ +/** Факел: рукоять 1×4×1 (обмотка у головки), пламя с ярким ядром; угол — низ рукояти. */ export function torchModel(p: GearPalette = {}): VoxelModel { const c = { ...GEAR_DEFAULTS, ...p }; - const g = new VoxelGrid(1, 6, 1); - for (let y = 0; y <= 2; y++) g.set(0, y, 0, 4); // древко - g.set(0, 3, 0, 2); // обмотка - g.set(0, 4, 0, 5); // пламя - g.set(0, 5, 0, 6); // ядро пламени + const g = new VoxelGrid(1, 4, 1); + g.set(0, 0, 0, 4); // древко + g.set(0, 1, 0, 2); // обмотка + g.set(0, 2, 0, 5); // пламя + g.set(0, 3, 0, 6); // ядро пламени return encodeModel(g, { 2: c.strap!, 4: c.wood!, 5: c.flame!, 6: c.flameCore! }); } /** - * Фонарик: ручной цилиндр 2×2×5 вдоль +Z (хвост у кисти, линза светится + * Фонарик: ручной цилиндр 2×2×4 вдоль +Z (хвост у кисти, линза светится * впереди — торчит за фасад руки); угол-origine — левый нижний задний угол. - * На манекене ставится в левую кисть (attach pos ≈ [0.5, 5, 0], кость armL — + * На манекене ставится в левую кисть (attach pos ≈ [−0.5, 3, 0], кость armL — * зеркально факелу, колонка x0 свободна). Луч даёт addSpotLight рендера. */ export function flashlightModel(p: GearPalette = {}): VoxelModel { const c = { ...GEAR_DEFAULTS, ...p }; - const g = new VoxelGrid(2, 2, 5); - for (let z = 0; z <= 3; z++) + const g = new VoxelGrid(2, 2, 4); + for (let z = 0; z <= 2; z++) for (let y = 0; y <= 1; y++) - for (let x = 0; x <= 1; x++) g.set(x, y, z, z === 1 || z === 2 ? 3 : 1); // корпус, хват - for (let y = 0; y <= 1; y++) for (let x = 0; x <= 1; x++) g.set(x, y, 4, 2); // линза + for (let x = 0; x <= 1; x++) g.set(x, y, z, z === 1 ? 3 : 1); // корпус, хват + for (let y = 0; y <= 1; y++) for (let x = 0; x <= 1; x++) g.set(x, y, 3, 2); // линза return encodeModel(g, { 1: c.strap!, 2: c.flameCore!, 3: c.metal! }); } diff --git a/v2/packages/engine/src/models/mannequin.ts b/v2/packages/engine/src/models/mannequin.ts index aa80ede..a3e7eb6 100644 --- a/v2/packages/engine/src/models/mannequin.ts +++ b/v2/packages/engine/src/models/mannequin.ts @@ -1,10 +1,11 @@ /** * Манекен — эталонная модель для рига и анимации (docs/plan.md, трек 6/26). - * Путник 10×16×4 (~1.6 тайла, ~164 вокс — втрое меньше трека 25): деталя - * живёт ТЕКСТУРОЙ, а не геометрией (слой tiles/tex формата). Лицо — арт - * 8×8 текселей (4×4 вокселя фасада × тайли 2×2), волосы — бахрома в арте - * и шапка на затылке/макушке. Правило ракурса (трек 25): камера сверху - * ~55° — над фасадом лица (+Z) в той же z-колонке вокселей выше нет. + * Путник 8×12×4 (~1.2 тайла, ~134 вокс; фидбек — персонажи меньше на фоне + * выросших деревьев): деталя живёт ТЕКСТУРОЙ, а не геометрией (слой + * tiles/tex формата) — при уменьшении сетки проработка не теряется. Лицо — + * арт 8×8 текселей (4×4 вокселя фасада × тайли 2×2), волосы — бахрома в + * арте и шапка на затылке/макушке. Правило ракурса (трек 25): камера + * сверху ~55° — над фасадом лица (+Z) в той же z-колонке вокселей выше нет. * Палитра — параметр: у героя и NPC свои цвета (тексели ссылаются на слоты * палитры, перекраска красит и текстуры). Части известны при генерации — * привязка к костям передаётся явно. @@ -93,7 +94,7 @@ /** Детерминированный манекен с ригой: root → legL/legR/armL/armR. */ export function mannequin(palette: MannequinPalette = {}): Mannequin { - const g = new VoxelGrid(10, 16, 4); + const g = new VoxelGrid(8, 12, 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 => { @@ -109,27 +110,26 @@ for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) put(x, y, z, slot, bone); }; - // ноги: колонны 1×1 (x3 и x6, z2), y1..4; сапоги y0 с мыском к +Z - box(3, 3, 1, 4, 2, 2, LEG, 1); - box(6, 6, 1, 4, 2, 2, LEG, 2); - put(3, 0, 2, BOOT, 1); put(3, 0, 3, BOOT, 1); - put(6, 0, 2, BOOT, 2); put(6, 0, 3, BOOT, 2); - // торс: юбка плаща y5..6 шире, пояс y7, грудь y8..9, плечи y10 - box(2, 7, 5, 6, 1, 3, TORSO, 0); - box(3, 6, 7, 7, 1, 3, BELT, 0); - box(3, 6, 8, 9, 1, 3, TORSO, 0); - box(2, 7, 10, 10, 1, 3, TORSO, 0); - put(4, 7, 3, METAL, 0); put(5, 7, 3, METAL, 0); // пряжка на поясе - // руки: рукава (x1 и x8, z2) y7..10, кисти (кожа) y5..6 - box(1, 1, 7, 10, 2, 2, ARM, 3); - box(8, 8, 7, 10, 2, 2, ARM, 4); - put(1, 5, 2, SKIN, 3); put(1, 6, 2, SKIN, 3); - put(8, 5, 2, SKIN, 4); put(8, 6, 2, SKIN, 4); - // шея и голова: y11 шея, голова y12..15; волосы — макушка (y15) и затылок (z1) - box(4, 5, 11, 11, 2, 2, SKIN, 0); - box(3, 6, 12, 15, 1, 3, SKIN, 0); - box(3, 6, 15, 15, 1, 3, HAIR, 0); - box(3, 6, 12, 14, 1, 1, HAIR, 0); + // ноги: колонны 1×1 (x2 и x5, z2), y1..3; сапоги y0 с мыском к +Z + box(2, 2, 1, 3, 2, 2, LEG, 1); + box(5, 5, 1, 3, 2, 2, LEG, 2); + put(2, 0, 2, BOOT, 1); put(2, 0, 3, BOOT, 1); + put(5, 0, 2, BOOT, 2); put(5, 0, 3, BOOT, 2); + // торс: юбка плаща y4..5 шире, пояс y6, грудь/плечи y7 (один слой) + box(1, 6, 4, 5, 1, 3, TORSO, 0); + box(2, 5, 6, 6, 1, 3, BELT, 0); + box(1, 6, 7, 7, 1, 3, TORSO, 0); + put(3, 6, 3, METAL, 0); put(4, 6, 3, METAL, 0); // пряжка на поясе + // руки: рукава (x0 и x7, z2) y5..7, кисти (кожа) y3..4 — у юбки + box(0, 0, 5, 7, 2, 2, ARM, 3); + box(7, 7, 5, 7, 2, 2, ARM, 4); + put(0, 3, 2, SKIN, 3); put(0, 4, 2, SKIN, 3); + put(7, 3, 2, SKIN, 4); put(7, 4, 2, SKIN, 4); + // голова y8..11 (без шеи — голова сидит на плечах); волосы — макушка + // (y11) и затылок (z1) + box(2, 5, 8, 11, 1, 3, SKIN, 0); + box(2, 5, 11, 11, 1, 3, HAIR, 0); + box(2, 5, 8, 10, 1, 1, HAIR, 0); // фасад лица (+Z, z3): арт 8×8 текселей тайлями 2×2 const tiles: Record = {}; const tex = new Uint8Array(g.data.length); @@ -139,7 +139,7 @@ }; for (let dy = 0; dy < 4; dy++) { for (let dx = 0; dx < 4; dx++) { - const x = 3 + dx, y = 15 - dy; + const x = 2 + dx, y = 11 - dy; setTex(x, y, 3, 1 + dy * 4 + dx, faceTile(g.get(x, y, 3), dx, dy)); } } @@ -148,9 +148,9 @@ const hem = skinTile(TORSO, [TORSO, TORSO, TORSO, BELT]); const clasp = skinTile(TORSO, [TORSO, BELT, TORSO, BELT]); const sole = skinTile(BOOT, [BOOT, BOOT, EYE, EYE], true); - for (let x = 2; x <= 7; x++) for (let z = 1; z <= 3; z++) setTex(x, 5, z, 17, hem); - for (let y = 8; y <= 9; y++) for (let z = 1; z <= 3; z++) setTex(6, y, z, 18, clasp); - for (const [bx, bz] of [[3, 2], [3, 3], [6, 2], [6, 3]] as const) setTex(bx, 0, bz, 19, sole); + for (let x = 1; x <= 6; x++) for (let z = 1; z <= 3; z++) setTex(x, 4, z, 17, hem); + for (let z = 1; z <= 3; z++) setTex(5, 7, z, 18, clasp); + for (const [bx, bz] of [[2, 2], [2, 3], [5, 2], [5, 3]] as const) setTex(bx, 0, bz, 19, sole); const model = encodeModel(g, { 4: palette.torso ?? '#5c3b2e', 6: palette.legs ?? '#5b5b66', @@ -164,11 +164,11 @@ }, { tiles, tex }); const rig = makeRig( [ - { name: 'root', parent: -1, pivot: [5, 0, 2.5] }, // земля, центр пят - { name: 'legL', parent: 0, pivot: [3.5, 5, 2.5] }, // бедро левой - { name: 'legR', parent: 0, pivot: [6.5, 5, 2.5] }, // бедро правой - { name: 'armL', parent: 0, pivot: [1.5, 11, 2.5] }, // плечо левой - { name: 'armR', parent: 0, pivot: [8.5, 11, 2.5] }, // плечо правой + { name: 'root', parent: -1, pivot: [4, 0, 2.5] }, // земля, центр пят + { name: 'legL', parent: 0, pivot: [2.5, 4, 2.5] }, // бедро левой + { name: 'legR', parent: 0, pivot: [5.5, 4, 2.5] }, // бедро правой + { name: 'armL', parent: 0, pivot: [0.5, 8, 2.5] }, // плечо левой + { name: 'armR', parent: 0, pivot: [7.5, 8, 2.5] }, // плечо правой ], 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 a928ca7..f3324c7 100644 --- a/v2/packages/engine/src/physics/__tests__/character.test.ts +++ b/v2/packages/engine/src/physics/__tests__/character.test.ts @@ -66,13 +66,13 @@ c.setMove([1, 0]); for (let i = 0; i < 30; i++) c.step(w, 1 / 60); const voxels = c.visual(); - expect(voxels.length).toBe(164); // все воксели манекена + expect(voxels.length).toBe(134); // все воксели манекена for (const v of voxels) { expect(Math.abs(v.pos[0] - c.body.pos[0])).toBeLessThanOrEqual(8); // модель 10 шириной expect(v.pos[1]).toBeGreaterThanOrEqual(c.body.pos[1]); } const idle = makeChar(); - expect(idle.visual().length).toBe(164); + expect(idle.visual().length).toBe(134); }); it('кубы поворачиваются с моделью: rotY = yaw на каждом вокселе', () => { diff --git a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts index 938494a..c02f971 100644 --- a/v2/packages/engine/src/render/__tests__/modelViewer.test.ts +++ b/v2/packages/engine/src/render/__tests__/modelViewer.test.ts @@ -21,8 +21,8 @@ it('статы манекена: воксели/кости/тайли считаются по сетке', () => { const { model, rig } = mannequin(); const s = modelStats(model, rig); - expect(s.size).toEqual([10, 16, 4]); - expect(s.voxels).toBe(164); + expect(s.size).toEqual([8, 12, 4]); + expect(s.voxels).toBe(134); expect(s.bones).toBe(5); // 16 тайлей лица + 3 скина (трек 29) expect(s.tiles).toBe(19); diff --git a/v2/packages/engine/src/render/particles.ts b/v2/packages/engine/src/render/particles.ts index 40f935f..f6aa415 100644 --- a/v2/packages/engine/src/render/particles.ts +++ b/v2/packages/engine/src/render/particles.ts @@ -4,7 +4,8 @@ * земле/воде (InstancedBufferGeometry). Не воксели: без освещения, теней и * AO — цвет и прозрачность на частицу, пересборка поз каждый кадр дёшева. * Цвет пишется в буфер как есть (hex-компоненты без colorspace-конверсий) — - * совпадает с палитрой. + * совпадает с палитрой. Освещение — умножение на tint слоя (setTint): + * без него частицы светятся в полной яркости и ночью (вне системы света). */ import * as THREE from 'three'; @@ -19,6 +20,8 @@ /** Ручка слоя частиц: поза слоя каждый кадр (до ёмкости; рост пересоздаёт). */ export interface ParticleLayer { update(points: readonly ParticlePoint[]): void; + /** Множитель цвета всего слоя (0xRRGGBB, по умолчанию белый). */ + setTint(color: number): void; dispose(): void; } @@ -43,7 +46,8 @@ const POINT_FRAG = ` varying vec3 vColor; varying float vAlpha; -void main() { gl_FragColor = vec4(vColor, vAlpha); }`; +uniform vec3 uTint; +void main() { gl_FragColor = vec4(vColor * uTint, vAlpha); }`; const QUAD_VERT = ` attribute vec3 iPos; @@ -72,7 +76,8 @@ geo.setAttribute('aSize', siz); const mat = new THREE.ShaderMaterial({ vertexShader: POINT_VERT, fragmentShader: POINT_FRAG, - uniforms: { uPxScale: { value: pxScale } }, vertexColors: true, ...FLAGS, + uniforms: { uPxScale: { value: pxScale }, uTint: { value: new THREE.Vector3(1, 1, 1) } }, + vertexColors: true, ...FLAGS, }); const mesh = new THREE.Points(geo, mat); mesh.frustumCulled = false; // bbox по инстансам не считается @@ -83,6 +88,7 @@ scene.remove(mesh); geo.dispose(); mat.dispose(); const fresh = createSpriteLayer(scene, points.length, pxScale); layer.update = fresh.update; + layer.setTint = fresh.setTint; layer.dispose = fresh.dispose; fresh.update(points); // грабля: редирект без позы терял первый кадр return; @@ -97,6 +103,10 @@ geo.setDrawRange(0, points.length); pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true; }, + setTint: (color) => { + const v = mat.uniforms.uTint.value as THREE.Vector3; + v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255); + }, dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); }, }; return layer; @@ -115,7 +125,10 @@ geo.setAttribute('iColor', col); geo.setAttribute('iAlpha', alp); geo.setAttribute('iSize', siz); - const mat = new THREE.ShaderMaterial({ vertexShader: QUAD_VERT, fragmentShader: POINT_FRAG, ...FLAGS }); + const mat = new THREE.ShaderMaterial({ + vertexShader: QUAD_VERT, fragmentShader: POINT_FRAG, + uniforms: { uTint: { value: new THREE.Vector3(1, 1, 1) } }, ...FLAGS, + }); const mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; scene.add(mesh); @@ -125,6 +138,7 @@ scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); const fresh = createQuadLayer(scene, points.length); layer.update = fresh.update; + layer.setTint = fresh.setTint; layer.dispose = fresh.dispose; fresh.update(points); // грабля: редирект без позы терял первый кадр return; @@ -139,6 +153,10 @@ geo.instanceCount = points.length; pos.needsUpdate = col.needsUpdate = alp.needsUpdate = siz.needsUpdate = true; }, + setTint: (color) => { + const v = mat.uniforms.uTint.value as THREE.Vector3; + v.set(((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255); + }, dispose: () => { scene.remove(mesh); geo.dispose(); mat.dispose(); base.dispose(); }, }; return layer; diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index a7dfef8..0604995 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -88,6 +88,9 @@ /** Изометрия демо: диметрия 2:1 — yaw 45°, тангенс угла возвышения 1/2. */ export const ISO_YAW = Math.PI / 4; const ISO_TAN = 0.5; +/** Максимальная высота кастера тени (высота чанка мира) — для радиуса + * теневого фрустума: тень кастера уходит от него на h/tan(elev солнца). */ +const SHADOW_CASTER_H = 64; /** Ручка пост-прохода: юниформы (uTime и пр. — игра анимирует сама) и отключение. */ export interface PostPassHandle { @@ -111,6 +114,8 @@ private readonly ambient: THREE.HemisphereLight; private readonly target: THREE.Vector3; private readonly viewSize: number; + /** Аспект кадра: полуширина видимой области = viewSize·max(aspect,1). */ + private readonly viewAspect: number; private sunK = 0.5; private nightK = 0; /** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */ @@ -131,6 +136,7 @@ const height = opts.height ?? 270; this.target = new THREE.Vector3(...(opts.target ?? [0, 0, 0])); this.viewSize = opts.viewSize ?? 16; + this.viewAspect = width / height; this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); this.renderer.setSize(width, height, false); // CSS-размер задаёт страница @@ -146,12 +152,10 @@ this.sun = new THREE.DirectionalLight(0xffffff, 1.4); this.sun.castShadow = true; - this.sun.shadow.mapSize.set(1024, 1024); - const r = this.viewSize * 1.2; - this.sun.shadow.camera.left = -r; - this.sun.shadow.camera.right = r; - this.sun.shadow.camera.top = r; - this.sun.shadow.camera.bottom = -r; + this.sun.shadow.mapSize.set(2048, 2048); // фрустум широкий — плотность теней та же + // левый/правый/верх/низ фрустума и far ставит setSun — радиус зависит + // от аспекта кадра и высоты солнца (статичный 1.2·viewSize был меньше + // видимой области: граница включения теней была видна как резкая линия) this.sun.shadow.bias = -0.0005; this.scene.add(this.sun); this.scene.add(this.sun.target); @@ -344,6 +348,23 @@ this.target.z + dist * Math.sin(az) * Math.cos(elev), ); this.sun.target.position.copy(this.target); + // Теневой фрустум накрывает всю видимую область (полуширина кадра + // viewSize·max(aspect,1) — иначе граница включения теней видна как + // резкая линия на экране) плюс длину тени самого высокого кастера + // (чанк 64) при текущей высоте солнца: тень уходит от кастера на + // h/tan(elev). far — от позиции солнца (dist) до дальнего края. + const visible = this.viewSize * Math.max(this.viewAspect, 1); + const r = Math.ceil(visible + SHADOW_CASTER_H / Math.tan(elev)); + const cam = this.sun.shadow.camera; + if (cam.right !== r) { + cam.left = -r; + cam.right = r; + cam.top = r; + cam.bottom = -r; + cam.near = 0.5; + cam.far = dist + r + SHADOW_CASTER_H + this.viewSize; + cam.updateProjectionMatrix(); + } // ночь гасит солнце до остаточной засветки (луна/небо), тёмный интерьер — и днём this.sun.intensity = (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK) * this.daySunScale;