diff --git a/docs/engine/render.md b/docs/engine/render.md index e273f79..93d0dbf 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -255,11 +255,21 @@ Псевдо-3D тени без настоящей геометрии: **блоб-тень** под каждым актёром и **направленная тень** у высоких объектов карты. Положение считает чистая математика `shadowSim.ts` по часам суток — солнце встаёт в `dawn`, садится в -`night` (те же границы, что у `dayNightFactor`): направление тени качается от -экранного запада утром до востока вечером (свет всегда «сверху-слева» по -арт-библии, `dy` фиксирован), длина растёт к низкому солнцу. Пепельный мир — -солнце рассеянное: тени мягкие, серые (`SHADOW_COLOR`), альфа в пике 0.34; -ночью `sunShadow` возвращает `null` — теней нет. +`night` (те же границы, что у `dayNightFactor`). Направление тени считается +**азимутом в плоскости мира** и проецируется изометрией (`DEFAULT_ISO`) — +любой вектор с этой дуги лежит на изо-земле: тень не «стекает по стене» и не +отрывает объект от пола. Утром тень уходит влево по экрану, в полдень — +компактно вниз, вечером — вправо; длина растёт к низкому солнцу. Пепельный +мир — солнце рассеянное: тени мягкие, серые (`SHADOW_COLOR`), альфа в пике +0.5; ночью `sunShadow` возвращает `null` — теней нет. + +**Геометрия блоба — «сидит на базе»**: центр массы эллипса лишь чуть съезжает +вдоль тени, ближний край заходит за базу объекта. Если центр ставить на +середине вылета (как в первой версии), видимая тёмная масса висит в стороне +от объекта — объект читается «летающим». Плотность ступеней текстуры +рассчитана на умножение на спрайт-альфу: слабые ступени × слабая альфа дают +невидимую тень (проверялось пикселями: центр блоба в разгар дня ~40% +затемнения). ```ts import { sunShadow, blobPlacement, makeShadowTexture, applyBlobShadow } from '@rpg/engine'; diff --git a/packages/engine/src/render/__tests__/shadowSim.test.ts b/packages/engine/src/render/__tests__/shadowSim.test.ts index 9d90b49..ea78658 100644 --- a/packages/engine/src/render/__tests__/shadowSim.test.ts +++ b/packages/engine/src/render/__tests__/shadowSim.test.ts @@ -42,15 +42,20 @@ }); describe('blobPlacement', () => { - it('эллипс тянется от базы вдоль направления тени', () => { + it('масса эллипса сидит у базы: ближний край заходит за базу, вылет — вперёд', () => { const sun = { dirX: 1, dirY: 0, len: 1, alpha: 0.3 }; const b = blobPlacement(10, 40, sun); - expect(b.x).toBeCloseTo((40 + 8) / 2); // reach = height·len + const center = 10 * 0.3 + 40 * 0.2; // 11 — центр массы от базы + expect(b.x).toBeCloseTo(center); expect(b.y).toBe(0); - expect(b.scaleX).toBeCloseTo(24); - expect(b.scaleY).toBeCloseTo(10); // 0.7 + 0.3·len = 1.0 при len=1 + expect(b.scaleX).toBeCloseTo(center + 5.5 + 8.8); // полуось вдоль тени + expect(b.scaleY).toBeCloseTo(10); // 0.75 + 0.25·min(1,len) = 1 при len=1 expect(b.alpha).toBeCloseTo(0.3); expect(b.rotation).toBeCloseTo(0); + // Ближний край эллипса за базой (тень «приклеена» к объекту)… + expect(b.x - b.scaleX).toBeLessThan(0); + // …а дальний край вылетает вперёд по тени. + expect(b.x + b.scaleX).toBeGreaterThan(0); }); it('длинная утренняя тень дальше, чем короткая полуденная', () => { @@ -63,4 +68,9 @@ const b = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 1, alpha: 0.3 }); expect(b.rotation).toBeCloseTo(Math.PI / 2); }); + + it('центр массы близко к базе даже при длинной тени (объект не «висит»)', () => { + const long = blobPlacement(9, 12, { dirX: -0.9, dirY: 0.45, len: 1.6, alpha: 0.3 }); + expect(Math.hypot(long.x, long.y)).toBeLessThan(9); // герой: центр ~5-6 px от ног + }); }); \ No newline at end of file diff --git a/packages/engine/src/render/shadowSim.ts b/packages/engine/src/render/shadowSim.ts index 3319195..766fdb5 100644 --- a/packages/engine/src/render/shadowSim.ts +++ b/packages/engine/src/render/shadowSim.ts @@ -1,12 +1,14 @@ /** * Чистая математика теней — без Pixi, тестируется в Vitest. * Солнце ходит по дуге в пределах дня из DayNightSpec (dawn..night): тень - * утром «на запад экрана», вечером «на восток», в полдень короткая. Мир - * пепельный — свет рассеянный, тени мягкие: альфа скромная, края ступенчатые - * (рисует makeShadowTexture), геометрия — вытянутый эллипс от базы объекта. + * утром «влево по экрану», вечером «вправо», в полдень короткая; направление + * проецируется изометрией из плоскости мира — тень всегда лежит на земле. + * Мир пепельный — свет рассеянный, тени мягкие: альфа скромная, края + * ступенчатые (рисует makeShadowTexture), геометрия — эллипс, сидящий на базе. */ import { type DayNightSpec, DEFAULT_DAY_NIGHT } from './lightSim'; +import { DEFAULT_ISO } from '../math/iso'; /** Цвет тени — нейтральный тёмно-серый (пепельная гамма, не чёрный). */ export const SHADOW_COLOR = 0x2f2f38; @@ -19,7 +21,7 @@ /** Состояние солнца для теней: направление тени (экран), длина, альфа. */ export interface SunShadow { - /** Единичный вектор тени на экране (dx вправо, dy вниз). */ + /** Единичный вектор тени на экране (dx вправо, dy вниз); лежит на изо-земле. */ dirX: number; dirY: number; /** Длина тени на единицу высоты объекта (короткая в полдень). */ @@ -31,8 +33,10 @@ /** * Солнце по часам суток: null — ночи (тьма рисует darkness-слой, тени от * солнца не видны). Границы и разгон альфы — из DayNightSpec: подъём - * dawn..day, спад dusk..night; направление качается от «на запад экрана» - * утром до «на восток» вечером. + * dawn..day, спад dusk..night. Направление тени считается АЗИМУТОМ в + * плоскости мира и проецируется изометрией (мировой юг → экран влево-вниз, + * мировая диагональ юго-восток → ровно вниз): любой вектор с этой дуги + * лежит на земле — тень не «стекает по стене» и не отрывает объект от пола. */ export function sunShadow(hours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): SunShadow | null { const t = ((hours % 24) + 24) % 24; @@ -42,11 +46,14 @@ const up = smooth((t - rise) / Math.max(0.001, spec.day - rise)); const down = 1 - smooth((t - spec.dusk) / Math.max(0.001, set - spec.dusk)); const k = (t - rise) / (set - rise); // 0..1 — путь солнца за день - const dx = -1 + 2 * k; - const dy = 0.6; // свет всегда «сверху» экрана — тень чуть вниз - const n = Math.hypot(dx, dy); + const az = (Math.PI / 2) * (1 - k); // мировой юг (утро) → юго-восток (вечер) + const gx = Math.cos(az); + const gy = Math.sin(az); + const px = (gx - gy) * (DEFAULT_ISO.tileW / 2); + const py = (gx + gy) * (DEFAULT_ISO.tileH / 2); + const n = Math.hypot(px, py); const len = 0.55 + 1.05 * Math.pow(Math.abs(k - 0.5) * 2, 1.4); - return { dirX: dx / n, dirY: dy / n, len, alpha: 0.34 * Math.min(up, down) }; + return { dirX: px / n, dirY: py / n, len, alpha: 0.5 * Math.min(up, down) }; } /** Размещение блоба-тени относительно базы объекта (в px виртуального экрана). */ @@ -64,18 +71,20 @@ } /** - * Эллипс тени для объекта высотой heightPx с «толщиной» radiusPx: тень - * начинается у базы и тянется вдоль направления на heightPx·len, ширина - * поперёк почти не меняется от длины. + * Эллипс тени для объекта высотой heightPx с «толщиной» radiusPx. Масса + * эллипса сидит у базы: центр лишь чуть съезжает вдоль тени, а ближний край + * заходит за базу — иначе видимая тёмная масса висит в стороне от объекта + * и тот читается «летающим». Вылет вдоль направления растёт с высотой и + * низким солнцем (len), ширина поперёк почти не меняется от длины. */ export function blobPlacement(radiusPx: number, heightPx: number, sun: SunShadow): BlobPlacement { const reach = heightPx * sun.len; - const tip = reach + radiusPx * 0.8; // дальний край тени от базы - const a = tip / 2; - const b = radiusPx * (0.7 + 0.3 * sun.len); + const center = radiusPx * 0.3 + reach * 0.2; // центр массы от базы + const a = center + radiusPx * 0.55 + reach * 0.22; // полуось вдоль тени + const b = radiusPx * (0.75 + 0.25 * Math.min(1, sun.len)); // полуось поперёк return { - x: sun.dirX * a, - y: sun.dirY * a, + x: sun.dirX * center, + y: sun.dirY * center, rotation: Math.atan2(sun.dirY, sun.dirX), scaleX: a, scaleY: b, diff --git a/packages/engine/src/render/shadows.ts b/packages/engine/src/render/shadows.ts index 603b014..2ebfb42 100644 --- a/packages/engine/src/render/shadows.ts +++ b/packages/engine/src/render/shadows.ts @@ -14,9 +14,12 @@ /** Текстура блоба-тени: три ступени прозрачности от края к центру. */ export function makeShadowTexture(renderer: Renderer): Texture { const g = new Graphics(); - g.ellipse(0, 0, SHADOW_TEX_PX, SHADOW_TEX_PX).fill({ color: SHADOW_COLOR, alpha: 0.34 }); - g.ellipse(0, 0, SHADOW_TEX_PX * 0.72, SHADOW_TEX_PX * 0.72).fill({ color: SHADOW_COLOR, alpha: 0.36 }); - g.ellipse(0, 0, SHADOW_TEX_PX * 0.45, SHADOW_TEX_PX * 0.45).fill({ color: SHADOW_COLOR, alpha: 0.3 }); + // Ступени с запасом: спрайт-альфа (до ~0.5 в разгар дня) умножается + // поверх — суммарная плотность центра должна оставаться читаемой на + // тёмной траве, иначе объект «висит» без контактной тени. + g.ellipse(0, 0, SHADOW_TEX_PX, SHADOW_TEX_PX).fill({ color: SHADOW_COLOR, alpha: 0.4 }); + g.ellipse(0, 0, SHADOW_TEX_PX * 0.72, SHADOW_TEX_PX * 0.72).fill({ color: SHADOW_COLOR, alpha: 0.44 }); + g.ellipse(0, 0, SHADOW_TEX_PX * 0.45, SHADOW_TEX_PX * 0.45).fill({ color: SHADOW_COLOR, alpha: 0.5 }); const tex = renderer.app.renderer.generateTexture({ target: g, resolution: 1 }); g.destroy(); return tex;