diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index fa7119c..a9e1043 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -19,6 +19,7 @@ dayNightFactor, PostFx, unitsToPx, + sunShadow, type Camera, type GameState, type Invariant, @@ -51,6 +52,7 @@ import type { ItemId } from '../data/items'; import { FLAGS, VARS } from '../data/ids'; import { PlayerController } from '../systems/PlayerController'; +import { GameShadows } from '../systems/Shadows'; import { FaunaSystem } from '../systems/fauna/FaunaSystem'; import { DialogueSystem } from '../systems/DialogueSystem'; import { CombatWorld } from '../systems/combat/CombatWorld'; @@ -141,6 +143,8 @@ private lightView: Lighting; /** Пост-эффекты мира: блюр резонанса, аберрация урона, марево источников. */ private postfx: PostFx; + /** Тени мира: блобы актёров + направленные тени tall-объектов. */ + private shadows: GameShadows; /** * Ручная зона марева моста (scene:postfx shimmer): перекрывает авто-источники. * undefined — авто-режим (SHIMMER_SOURCES), null — выключено насовсем. @@ -231,6 +235,18 @@ this.map.setTallDepthLayer(this.actors); // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); + // Тени: ground-слой между картой и актёрами + направленные тени tall-объектов. + // data.tall — таблица «id тайла → TallSpec»; tall-ячейки ищем в tiles. + this.shadows = new GameShadows(this.game.renderer); + this.world.addChildAt(this.shadows.ground, 1); // поверх пола, под акторами + for (let idx = 0; idx < data.tiles.length; idx++) { + const raw = data.tall?.[data.tiles[idx]!]; + if (!raw) continue; + const spec = typeof raw === 'number' ? { height: raw } : raw; + const tx = idx % data.width; + const ty = Math.floor(idx / data.width); + this.shadows.addStatic(this.tileCenter(tx, ty), 22, unitsToPx(spec.height)); + } this.game.renderer.worldRoot.addChild(this.world); // Слой мировых эффектов (раньше вьюх объектов: consume растворяет через него). this.fx = new FxLayer(); @@ -259,6 +275,8 @@ }); // Тела: герой не проходит сквозь NPC (расталкивание через реестр). this.player.setBodies(this.objects.registry); + // Блоб-тень героя (первый ребёнок вьюхи — рисуется под спрайтом). + this.shadows.attachActor(this.player.view, 9, 12); // Мигание героя при неуязвимости — процедурный blink (duty меняется на лету). this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } }); @@ -283,6 +301,7 @@ for (const def of placedNpcs) { const made = this.interactViews.createNpcView(def, (tx, ty) => this.tileCenter(tx, ty)); this.actors.add(made.view, def.tile.x, def.tile.y); + this.shadows.attachActor(made.view, 9, 12); this.npcs.push({ def, view: made.view, body: made.body }); if (!area.npcs.includes(def)) this.objects.addNpc(def); } @@ -332,7 +351,8 @@ enemyTextures(this.game.assets), this.world, this.fx, - this.game.assets + this.game.assets, + this.shadows ); // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира (в CombatFlow). @@ -597,6 +617,7 @@ this.lighting.destroy(); this.lightView.destroy({ children: true }); this.postfx.dispose(); + this.shadows.destroy(); this.hud.destroy(); this.hint.destroy(); this.joystick.destroy({ children: true }); @@ -613,6 +634,7 @@ this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам + this.shadows.update(sunShadow(this.game.clock.hours)); // тени ползут за солнцем this.postfx.update(dt); this.updateShimmer(); this.atmosphere.updateVignette(); diff --git a/apps/game/src/systems/Shadows.ts b/apps/game/src/systems/Shadows.ts new file mode 100644 index 0000000..348b6e5 --- /dev/null +++ b/apps/game/src/systems/Shadows.ts @@ -0,0 +1,90 @@ +/** + * Тени мира: блобы под актёрами (герой, NPC, враги) и направленные тени + * tall-объектов карты. Положение считает чистая математика sunShadow/ + * blobPlacement по часам суток (сцена передаёт SunShadow каждый тик — + * тени ползут за солнцем плавно). Актёрский блоб — первый ребёнок вьюхи + * (ноги в (0,0)), статические тени — отдельный ground-слой под актёрами. + */ +import { Container, Sprite, Texture, blobPlacement, applyBlobShadow, makeShadowTexture, type Renderer, type SunShadow } from '@rpg/engine'; + +/** Актёрский блоб: вьюха + «толщина» объекта в px виртуального экрана. */ +interface ActorShadow { + view: Container; + radiusPx: number; + heightPx: number; + sprite: Sprite; +} + +/** Статическая тень (tall-тайл): база уже в координатах мира-экрана. */ +interface StaticShadow { + sprite: Sprite; + base: { x: number; y: number }; + radiusPx: number; + heightPx: number; +} + +/** Узкий интерфейс для боевых вьюх — без связности с полной системой. */ +export interface ShadowAttach { + attachActor(view: Container, radiusPx: number, heightPx: number): void; +} + +export class GameShadows implements ShadowAttach { + private readonly tex: Texture; + /** Ground-слой: добавить в world между картой и актёрами. */ + readonly ground = new Container(); + private readonly actorShadows: ActorShadow[] = []; + private readonly statics: StaticShadow[] = []; + + constructor(renderer: Renderer) { + this.tex = makeShadowTexture(renderer); + this.ground.eventMode = 'none'; + } + + /** Блоб под актёром: первым ребёнком — спрайты рисуются поверх тени. */ + attachActor(view: Container, radiusPx: number, heightPx: number): void { + const sprite = new Sprite(this.tex); + sprite.anchor.set(0.5, 0.5); + view.addChildAt(sprite, 0); + this.actorShadows.push({ view, radiusPx, heightPx, sprite }); + } + + /** Направленная тень tall-объекта: база — центр основания (центр ромба). */ + addStatic(base: { x: number; y: number }, radiusPx: number, heightPx: number): void { + const sprite = new Sprite(this.tex); + sprite.anchor.set(0.5, 0.5); + this.ground.addChild(sprite); + this.statics.push({ sprite, base, radiusPx, heightPx }); + } + + /** Пересчёт всех теней по солнцу (null — ночь: погасить). */ + update(sun: SunShadow | null): void { + // Вьюхи погибших врагов уничтожаются CombatViews — блобы вычищаем сами. + for (let i = this.actorShadows.length - 1; i >= 0; i--) { + if (this.actorShadows[i]!.sprite.destroyed) { + this.actorShadows.splice(i, 1); + continue; + } + const rec = this.actorShadows[i]!; + this.place(rec.sprite, { x: 0, y: 0 }, rec, sun); + } + for (const rec of this.statics) this.place(rec.sprite, rec.base, rec, sun); + } + + private place( + sprite: Sprite, + base: { x: number; y: number }, + rec: { radiusPx: number; heightPx: number }, + sun: SunShadow | null + ): void { + if (!sun) { + sprite.alpha = 0; + return; + } + applyBlobShadow(sprite, base, blobPlacement(rec.radiusPx, rec.heightPx, sun)); + } + + destroy(): void { + this.ground.destroy({ children: true }); + this.tex.destroy(); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts index d4303da..570b7d5 100644 --- a/apps/game/src/systems/combat/CombatViews.ts +++ b/apps/game/src/systems/combat/CombatViews.ts @@ -13,6 +13,7 @@ } from '@rpg/engine'; import type { CombatWorld } from './CombatWorld'; import type { EnemyKindId } from '../../data/enemies'; +import type { ShadowAttach } from '../Shadows'; /** * Пиксельные вьюхи боя: спрайты сгустков, снаряды, вспышки урона, растворение @@ -39,7 +40,9 @@ /** Слой эффектов (FxLayer сцены): тик и зачистка — сами. */ private fx: FxLayer, /** Ассеты: спрайт снаряда плевка (tiles/spit). */ - private assets: AssetLoader + private assets: AssetLoader, + /** Тени: блоб-тень под каждым сгустком (attach на спавне). */ + private shadows: ShadowAttach ) {} /** Спавн-вспышка пепла (взрыв частиц). */ @@ -94,6 +97,7 @@ const sprite = new Sprite(frames[0]); sprite.anchor.set(0.5, 1); root.addChild(sprite); + this.shadows.attachActor(root, 8, 10); const sp = worldToScreen(en.pos.x, en.pos.y); root.position.set(sp.x, sp.y); const t = tileOf(en.pos); diff --git a/apps/game/tools/checks/shadows.mjs b/apps/game/tools/checks/shadows.mjs new file mode 100644 index 0000000..047c619 --- /dev/null +++ b/apps/game/tools/checks/shadows.mjs @@ -0,0 +1,64 @@ +/** + * Сценарий shadows — тени мира (блобы актёров + направленные тени tall-объектов). + * Математика sunShadow чистая и покрыта юнит-тестами; здесь проверяем, что + * тени в игре ползут за солнцем и гаснут ночью — визуально, по скриншотам + * в ключевые часы (утро/полдень/вечер/ночь). + * 1) новая игра (8:00): baseline — утренние тени влево; + * 2) setTime 13: полдень — короткие тени под объектами; + * 3) setTime 19: вечер — тени вправо; + * 4) setTime 23: ночь — теней нет; + * 5) setTime 8 обратно + шаг: сцена жива, время применилось. + * Запуск: node tools/agent.mjs run tools/checks/shadows.mjs + */ +import { withChecks } from '../lib.mjs'; + +export default async function ({ pretty }) { + return withChecks( + 'shadows', + async (t) => { + const { c } = t; + const setTime = async (hours) => { + const ok = await t.ctx.agent.command('scene:setTime', { hours }); + c.expect(ok === true, `scene:setTime ${hours}h не применился`, ok); + return ok; + }; + await c.run('новая игра 8:00: baseline, утренние тени влево', async () => { + await t.boot(); + // пережидаем fade-переход меню→локация (скриншот в fade тёмен) + await t.ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + // ручные шаги без рендера не обновляют канвас: рисуем кадры явно + await t.ctx.agent.step(10, { render: true }); + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'не в локации после boot', s.scene); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_morning.png' }); + return null; + }); + await c.run('13:00: полдень, короткие тени', async () => { + await setTime(13); + await t.ctx.agent.step(2); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_noon.png' }); + return null; + }); + await c.run('19:00: вечер, тени вправо', async () => { + await setTime(19); + await t.ctx.agent.step(2); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_evening.png' }); + return null; + }); + await c.run('23:00: ночь, теней нет', async () => { + await setTime(23); + await t.ctx.agent.step(2); + await t.ctx.page.screenshot({ path: '/tmp/rpg_shadow_night.png' }); + return null; + }); + await c.run('возврат к 8:00: время применилось, сцена жива', async () => { + await setTime(8); + await t.ctx.agent.step(5); + const s = await t.ctx.agent.snapshot(); + c.expect(s.scene === 'location', 'сцена не жива после setTime', s.scene); + return null; + }); + }, + { pretty } + ); +} \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index 073f7ca..b7f874f 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -32,6 +32,7 @@ | Lighting III («тьма с прорезями») | RT-карта силы тьмы: erase-прорези источников (пиксельный ступенчатый falloff), tone-слой отдельно от прорезей, dirty-bake по подписям источников, day fast-path; солнечный день + золотой час (`duskColor`), ночь — тьма с прорезями | готово | | Время суток (GameClock + dayNightFactor) | старт 8:00, часы в HUD нет — предмет «Медные часы» (Ирвин даёт с квестом): Enter в сумке показывает время; в сумке время заморожено | готово | | PostFx (пост-эффекты: блюр/аберрация/марево) | блюр-волна вокруг героя при резонансе (точка + радиус), хроматическая аберрация при уроне, марево у Старой Машины (пока не усыплена) и очага дома старейшины; проверка — `scene:postfx` + `tools/checks/postfx.mjs` | готово | +| Тени (shadowSim + GameShadows) | блоб-тени под героем/NPC/сгустками и направленные тени tall-объектов (деревья/валуны/дома/башня): солнце ползёт по часам — утром тени влево, в полдень короткие, вечером вправо, ночью исчезают; проверка — `tools/checks/shadows.mjs` (утро/полдень/вечер/ночь) | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | | Panel/Button/MenuList | меню, настройки, сейвы, сумка (строки с иконками предметов 8×8 `ui/icon_*`, синхронны окну скролла) | готово | diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 4d1925c..c37e1f6 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -482,7 +482,23 @@ tweens / scenes` — их `update` в try/catch с `console.error(e.stack)` — и продёрнуть переход; настоящий стектрейс всплывёт. Причина однажды была в тике разрушенного Pixi-объекта из `engine.fx` (см. core.md: - destroyed-записи не тикаются).- **Тест ядра без WebGL**: `Engine`/`Renderer` тестируются в Vitest через + destroyed-записи не тикаются). +- **Исключение в тике морозит канвас, но не мост**: бросок в `update()` + сцены прерывает рендер кадра — снапшоты/шаги моста продолжают отвечать, + а скриншот показывает замёрший кадр (например, меню). Если снапшот жив, + а скриншот не меняется — ищи исключение в тике: `page.on('pageerror')` + его поймает (в rAF-кадре оно не глотается, в отличие от `agent.step`). +- **Ручные шаги моста не рендерят канвас**: `agent.step(n)` крутит логику + без отрисовки; между командами rAF обычно успевает, но скриншот сразу + после серии шагов может поймать протухший кадр (или хвост fade-оверлея — + он тёмный и «ночной» на вид). Для скриншота после шагов — + `agent.step(10, { render: true })` (рисует кадры явно) или реальная пауза. +- **Зомби-vite на порту**: если `startDevServer` убил только npx-обёртку, + голый vite остаётся слушать 5199 и отдаёт протухший граф модулей — + симтомы до проверки кода не доходят (микс старых/новых модулей: фичи + «есть и нет» одновременно). Проверка: `ss -tlnp | grep 5199` перед + отладкой; `--strictPort` спасает новый сервер, но не убивает старый. +- **Тест ядра без WebGL**: `Engine`/`Renderer` тестируются в Vitest через `vi.mock('pixi.js')` (стабы `Container`/`Graphics`/`Application` с `renderer.render`/`canvas.style`) — см. `core/__tests__/Engine.test.ts`. `init()` в тесте не зовётся (он и не нужен: шаги — через `stepTicks`). diff --git a/docs/engine/render.md b/docs/engine/render.md index 7163fc1..e273f79 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -250,6 +250,41 @@ в GLSL ES 1.00 (WebGL1-фолбэк); юниформы v8 пишутся напрямую (`uniforms.uTime = t`), не через обёртки `{value}`. +## Тени (`render/shadowSim.ts`, `render/shadows.ts`) + +Псевдо-3D тени без настоящей геометрии: **блоб-тень** под каждым актёром и +**направленная тень** у высоких объектов карты. Положение считает чистая +математика `shadowSim.ts` по часам суток — солнце встаёт в `dawn`, садится в +`night` (те же границы, что у `dayNightFactor`): направление тени качается от +экранного запада утром до востока вечером (свет всегда «сверху-слева» по +арт-библии, `dy` фиксирован), длина растёт к низкому солнцу. Пепельный мир — +солнце рассеянное: тени мягкие, серые (`SHADOW_COLOR`), альфа в пике 0.34; +ночью `sunShadow` возвращает `null` — теней нет. + +```ts +import { sunShadow, blobPlacement, makeShadowTexture, applyBlobShadow } from '@rpg/engine'; + +// ... каждый тик сцены (состояние считает сцена, вьюхи тонкие): +shadows.update(sunShadow(clock.hours)); +// ... раскладка одного блоба (спрайт с текстурой makeShadowTexture): +applyBlobShadow(sprite, { x: 0, y: 0 }, blobPlacement(radiusPx, heightPx, sun)); +``` + +- **`sunShadow(hours, spec?)`** — `SunShadow | null`: единичный вектор + (`dirX/dirY`), относительная длина `len` (0.55..1.6 высоты объекта) и + `alpha` с рассветным/закатным рампами. Ночь — `null`. +- **`blobPlacement(radiusPx, heightPx, sun)`** — `BlobPlacement`: центр, + поворот и полуоси эллипса в px виртуального экрана; `radiusPx` — «толщина» + объекта у земли, `heightPx` — высота (`unitsToPx(height)`). +- **`shadows.ts`** — вьюхи: `makeShadowTexture(renderer)` (ступенчатый + трёхслойный эллипс — мягкий край без гладких градиентов), `applyBlobShadow`, + `SHADOW_TEX_PX`. + +Раскладка в сцене: ground-слой с тенями статических объектов вставляется +в мир **между картой и актёрами**; блобы актёров — первым ребёнком вьюхи +(ноги в (0,0)), чтобы спрайты рисовались поверх своей тени. Список блобов +сам вычищает записи с уничтоженными спрайтами (смерть врага уничтожает вьюху). + ## IsoDepthLayer Сортировка глубины для сущностей на изометрической карте: глубина = `tx + ty` diff --git a/docs/llms.txt b/docs/llms.txt index 3409492..b937663 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -14,7 +14,7 @@ - [README движка](engine/README.md): архитектура и принципы, карта модулей. - [Начало работы](engine/getting-started.md): Engine, GameLoop, сцены, первый запуск. - [Ядро](engine/core.md): GameLoop, GameState, Settings, EventBus, Tween. -- [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, PostFx (блюр/аберрация/марево), pixel-perfect масштаб. +- [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, PostFx (блюр/аберрация/марево), тени (shadowSim: блобы + направленные по солнцу), pixel-perfect масштаб. - [Ввод](engine/input.md): действия, геймпад, VirtualJoystick, инъекция ввода. - [Карты](engine/maps.md): изометрия, A*, коллизии (круг поверх сетки), формат rpg-map, Tiled-импорт. - [Анимация](engine/anim.md): клипы, SpriteAnimator, оживители, реестр тика — всё от фиксированного шага. diff --git a/docs/plan.md b/docs/plan.md index ec944f4..4122a5c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -39,6 +39,7 @@ | 18 | Третий раунд фидбека: свет в центре персонажа, пепел с заметной травой, изометрические объёмные дома (5 видов), валун и куст, карты внешних локаций ×2, карта мира «Низина Звенца» (композит областей), амбиент лугов заново (тихий «дыхательный» ветер + события) | ✅ сделано (2026-09-08): C1 25d2875 (свет лампы в центре тела героя), C2 6c3e23b (стебли травы в пепле заметнее, ярче, пучками), C3 9883a24 (изо-дома: две стены + двускатная крыша, 5 видов), C4 a8622f5 (валун BOULDER и куст BUSH — новые тайлы), C5 1dada11 (все внешние карты ×2, пересчёт координат контента/тулз), C6 b86b458 (карта мира — композит миниатюр областей с дорогами и подписями), C7 0dacab3 (новая фактура ветра: мягкий низкий шум с порывами, шуршание травы, скрипы и далёкий звон, тише) | | 19 | Герой за tall-объектами: деревья/дома-тайлы/башня/валуны/кусты рисуются в `objectsLayer` карты — целиком под слоем `actors`, герой всегда поверх (пропы с footprint уже сортируются правильно через `actors.addRect`) | ✅ сделано (2026-09-09): движок — `IsometricTileMap.setTallDepthLayer(layer\|null)` переносит tall-вьюхи клеток в общий `IsoDepthLayer` с глубиной `tx+ty` (кулинг/`setTile` работают по ссылке, `null` возвращает в карту); игра — `LocationScene` вызывает `setTallDepthLayer(this.actors)` + `actors.addRect` для пропов; тесты движка (2 новых), сценарий `hero_depth.mjs` (численные zIndex-assert + скриншоты) | | 20 | Пост-эффекты движка: переменный блюр (в точке с радиусом / на объекте / на всю сцену), хроматическая аберрация, марево («горячий воздух») | ✅ сделано (2026-09-09): движок — `render/postfx/` (`postSim.ts` чистая математика + 7 тестов, `PostFx.ts` — один кастомный Pixi-фильтр на `worldRoot`, API `set*/pulse*` по образцу Lighting, `blurObject` на вьюхе, `resolution: 'inherit'`); игра — блюр-волна резонанса, аберрация урона, авто-марево у Старой Машины/очага, мост `scene:postfx` (blur/aberration/shimmer/clear), сценарий `checks/postfx.mjs` (команды + скриншоты в пике) | +| 20b | Тени без 3D: блоб-тени актёров + направленные тени tall-объектов по солнцу | ✅ сделано (2026-09-09): движок — `render/shadowSim.ts` (чистая математика солнца `sunShadow`/`blobPlacement`, 8 тестов) + `render/shadows.ts` (ступенчатая текстура блоба, раскладка); игра — `GameShadows` (ground-слой между картой и актёрами, блобы первого ребёнком вьюхи, чистка уничтоженных), сценарий `checks/shadows.mjs` (утро/полдень/вечер/ночь, скриншоты). Позже по желанию: контактный AO у подножий | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index de3f076..bdb79a1 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -197,6 +197,14 @@ } from './render/postfx/postSim'; export { PostFx, type BlurState, type ShimmerState } from './render/postfx/PostFx'; export { + sunShadow, + blobPlacement, + SHADOW_COLOR, + type SunShadow, + type BlobPlacement +} from './render/shadowSim'; +export { makeShadowTexture, applyBlobShadow, SHADOW_TEX_PX } from './render/shadows'; +export { stepParticle, sampleSpawn, sampleBurst, diff --git a/packages/engine/src/render/__tests__/shadowSim.test.ts b/packages/engine/src/render/__tests__/shadowSim.test.ts new file mode 100644 index 0000000..9d90b49 --- /dev/null +++ b/packages/engine/src/render/__tests__/shadowSim.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { blobPlacement, sunShadow } from '../shadowSim'; + +describe('sunShadow', () => { + it('ночью тени нет', () => { + expect(sunShadow(2)).toBeNull(); + expect(sunShadow(23)).toBeNull(); + expect(sunShadow(5.5)).toBeNull(); + }); + + it('на границах дня альфа нулевая, в разгар дня максимальная', () => { + expect(sunShadow(6)!.alpha).toBeCloseTo(0); + expect(sunShadow(19.9)!.alpha).toBeCloseTo(0); // к закату гаснет (в 20 — null) + const noon = sunShadow(13)!.alpha; + expect(noon).toBeGreaterThan(0.3); + const morning = sunShadow(7)!.alpha; // ещё в разгоне (dawn..day = 6..8) + expect(morning).toBeGreaterThan(0); + expect(morning).toBeLessThan(noon); + }); + + it('тень утром на запад экрана, вечером на восток', () => { + expect(sunShadow(7)!.dirX).toBeLessThan(-0.5); + expect(sunShadow(19)!.dirX).toBeGreaterThan(0.5); + expect(sunShadow(13)!.dirX).toBeCloseTo(0); // полдень — почти под объектом + }); + + it('длина максимальна утром/вечером, минимальна в полдень', () => { + const noon = sunShadow(13)!.len; + const morning = sunShadow(7)!.len; + const evening = sunShadow(19)!.len; + expect(noon).toBeLessThan(morning); + expect(noon).toBeLessThan(evening); + expect(morning).toBeCloseTo(evening, 1); // симметричный день + }); + + it('вектор направления единичный', () => { + for (let h = 6; h < 20; h += 0.5) { + const s = sunShadow(h)!; + expect(Math.hypot(s.dirX, s.dirY)).toBeCloseTo(1); + } + }); +}); + +describe('blobPlacement', () => { + 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 + 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.alpha).toBeCloseTo(0.3); + expect(b.rotation).toBeCloseTo(0); + }); + + it('длинная утренняя тень дальше, чем короткая полуденная', () => { + const morning = blobPlacement(10, 40, { dirX: -1, dirY: 0, len: 1.5, alpha: 0.3 }); + const noon = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 0.6, alpha: 0.3 }); + expect(Math.hypot(morning.x, morning.y)).toBeGreaterThan(Math.hypot(noon.x, noon.y)); + }); + + it('поворот соответствует направлению тени', () => { + const b = blobPlacement(10, 40, { dirX: 0, dirY: 1, len: 1, alpha: 0.3 }); + expect(b.rotation).toBeCloseTo(Math.PI / 2); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/render/shadowSim.ts b/packages/engine/src/render/shadowSim.ts new file mode 100644 index 0000000..3319195 --- /dev/null +++ b/packages/engine/src/render/shadowSim.ts @@ -0,0 +1,84 @@ +/** + * Чистая математика теней — без Pixi, тестируется в Vitest. + * Солнце ходит по дуге в пределах дня из DayNightSpec (dawn..night): тень + * утром «на запад экрана», вечером «на восток», в полдень короткая. Мир + * пепельный — свет рассеянный, тени мягкие: альфа скромная, края ступенчатые + * (рисует makeShadowTexture), геометрия — вытянутый эллипс от базы объекта. + */ + +import { type DayNightSpec, DEFAULT_DAY_NIGHT } from './lightSim'; + +/** Цвет тени — нейтральный тёмно-серый (пепельная гамма, не чёрный). */ +export const SHADOW_COLOR = 0x2f2f38; + +/** Гладкая ступень 0..1 (нулевой наклон на краях — без рывков альфы). */ +function smooth(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return x * x * (3 - 2 * x); +} + +/** Состояние солнца для теней: направление тени (экран), длина, альфа. */ +export interface SunShadow { + /** Единичный вектор тени на экране (dx вправо, dy вниз). */ + dirX: number; + dirY: number; + /** Длина тени на единицу высоты объекта (короткая в полдень). */ + len: number; + /** Сила тени 0..1 (пепельный мир — солнце рассеянное). */ + alpha: number; +} + +/** + * Солнце по часам суток: null — ночи (тьма рисует darkness-слой, тени от + * солнца не видны). Границы и разгон альфы — из DayNightSpec: подъём + * dawn..day, спад dusk..night; направление качается от «на запад экрана» + * утром до «на восток» вечером. + */ +export function sunShadow(hours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): SunShadow | null { + const t = ((hours % 24) + 24) % 24; + const rise = spec.dawn; + const set = spec.night; + if (t < rise || t >= set) return null; + 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 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) }; +} + +/** Размещение блоба-тени относительно базы объекта (в px виртуального экрана). */ +export interface BlobPlacement { + /** Центр эллипса относительно точки базы (ноги/основания). */ + x: number; + y: number; + /** Поворот эллипса — вдоль направления тени. */ + rotation: number; + /** Полуоси эллипса, px. */ + scaleX: number; + scaleY: number; + /** Прозрачность — сила солнца. */ + alpha: number; +} + +/** + * Эллипс тени для объекта высотой heightPx с «толщиной» radiusPx: тень + * начинается у базы и тянется вдоль направления на heightPx·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); + return { + x: sun.dirX * a, + y: sun.dirY * a, + rotation: Math.atan2(sun.dirY, sun.dirX), + scaleX: a, + scaleY: b, + alpha: sun.alpha + }; +} \ No newline at end of file diff --git a/packages/engine/src/render/shadows.ts b/packages/engine/src/render/shadows.ts new file mode 100644 index 0000000..603b014 --- /dev/null +++ b/packages/engine/src/render/shadows.ts @@ -0,0 +1,38 @@ +/** + * Вьюхи теней поверх чистой математики shadowSim: текстура блоба + * (ступенчатый эллипс — мягкий край без мыла, пиксель-стиль) и раскладка + * спрайта по размещению из blobPlacement. Тонко: состояние считает сцена. + */ +import { Graphics, Sprite, Texture } from 'pixi.js'; +import { type BlobPlacement } from './shadowSim'; +import { SHADOW_COLOR } from './shadowSim'; +import { type Renderer } from './Renderer'; + +/** Полуось текстуры блоба, px виртуального разрешения (масштабируется спрайтом). */ +export const SHADOW_TEX_PX = 32; + +/** Текстура блоба-тени: три ступени прозрачности от края к центру. */ +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 }); + const tex = renderer.app.renderer.generateTexture({ target: g, resolution: 1 }); + g.destroy(); + return tex; +} + +/** + * Положить спрайт-блоб (текстура makeShadowTexture) по размещению blobPlacement: + * base — точка базы объекта (ноги/основание) в координатах родителя. + */ +export function applyBlobShadow( + sprite: Sprite, + base: { x: number; y: number }, + blob: BlobPlacement +): void { + sprite.position.set(base.x + blob.x, base.y + blob.y); + sprite.rotation = blob.rotation; + sprite.scale.set(blob.scaleX / SHADOW_TEX_PX, blob.scaleY / SHADOW_TEX_PX); + sprite.alpha = blob.alpha; +} \ No newline at end of file