diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 0ab1acd..4ffe73f 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -114,7 +114,9 @@ 0 круглая / 1 колонна / 2 ель), `boulderModel` (union сфер, свет на верхушках столбцов); (вид, сид) → VoxelModel, детерминировано; снаряжение (трек 32): `backpackModel` (6×7×2, 84 вокс — пряжка - ПЕРЕКРАШИвает воксели кожи, не добавляет), `torchModel` (1×6×1, 6 вокс); + ПЕРЕКРАШИвает воксели кожи, не добавляет), `torchModel` (1×6×1, 6 вокс), + `flashlightModel` (2×2×5 вдоль +Z, 20 вокс — трек 33: фонарик героя, + линза впереди торчит за фасад руки); палитра `GearPalette {leather?, strap?, metal?, wood?, flame?, flameCore?}`; - `src/models/stamp.ts` — `stampModel`: штамповка модели в мир с клиппингом @@ -487,7 +489,9 @@ интенсивность усиливается `POINT_LIGHT_GAIN = 8` — физические единицы three; трек 33: усиление и в `setIntensity` — иначе затирается мерцанием; `PointLightHandle` setIntensity/setPos — мерцание и лампа героя двигаются - на стороне игры). Игра: `src/clock.ts` (GameClock v1 1:1: 1 сек реального = + на стороне игры), `addSpotLight` (трек 33: конус света — фонарик героя; + `SpotLightHandle` setIntensity/setPos/setTarget — цель луча в мировой + точке). Игра: `src/clock.ts` (GameClock v1 1:1: 1 сек реального = 1 игровая минута, сутки 24 мин, старт 8:00) + тесты (порт clock.test v1); `src/data/lighting.ts` (LIGHTING 8 областей v1 1:1 — ambient/nightAmbient/ источники bells/окна/очаг по флагам/башня/прилавок + ATMOSPHERE); @@ -1218,18 +1222,19 @@ (равенство сдвигов предмета и вокселя руки до 1e-9 при повороте кости), тайли предметов сдвигаются за базу и в вокселях, и в CloudTiles. - **Генераторы** (`generators.ts`): `backpackModel` (84 вокс), `torchModel` - (6 вокс), палитра `GearPalette`. + (6 вокс), `flashlightModel` (20 вокс, трек 33), палитра `GearPalette`. - **Игра**: герой по умолчанию с рюкзаком (`bone 'root'`, pos [2,5,-1] — - за спиной, колонка z0 пуста в модели) и факелом (`bone 'armR'`, - pos [8.5,5,2] — сбоку кисти, колонка x9 не пересекается с рукавом x8). - Мост `heroGear(ids|null)` (null — снять всё; обновляет облако сам — мир + за спиной, колонка z0 пуста в модели), факелом (`bone 'armR'`, + pos [8.5,5,2] — сбоку кисти, колонка x9 не пересекается с рукавом x8) и + фонариком (`bone 'armL'`, pos [0.5,5,0] — зеркально, трубка вдоль +Z, + линза впереди; трек 33). Мост `heroGear(ids|null)` (null — снять всё; обновляет облако сам — мир может стоять в ручном режиме), `heroInfo.gear/gearVoxels`. `export type GearId` — реестр снаряжения в main.ts. - Гейт `tools/checks/gear.mjs` (18-й гейт игры, 6 проверок): счёт вокселей - сходится с генераторами (90/84/0), снятие меняет кадр (скриншоты - /tmp/v2_gear_*.png), ходьба со снаряжением не ломает физику. Визуально — - одноразовая проба `tools/probe-gear.mjs` (дневные кадры, зум-кропы: - рюкзак за спиной, пламя факела машет с рукой). + сходится с генераторами (110/84/0 с трека 33 — фонарик +20), снятие + меняет кадр (скриншоты /tmp/v2_gear_*.png), ходьба со снаряжением не + ломает физику. Визуально — одноразовая проба `tools/probe-gear.mjs` + (дневные кадры, зум-кропы: рюкзак за спиной, пламя факела машет с рукой). - Попутно — **два багфикса, найденных пробой**: - **пост-проход затемнял день ~втрое** (яркость дневного кадра 98→30 с трека 31): сцена рендерится в RT без выходной конверсии (linear), @@ -1280,6 +1285,38 @@ Каталог 20 записей (было 16 — добавились факел, рюкзак, бухта кабеля); id стабильны (`char:<имя>`, `deer`, `tree0`...) — гейт panels не менялся. +## Трек 34 (2026-09-12): тьма за краем карты, деревья крупнее, солнце едет, +## фонарик героя + +Четыре пункта фидбека (задачи #197-200): +- **Тьма за пределами карты**: неосвещаемая плоскость под миром в main.ts + (`MeshBasicMaterial` 0x050507 не берёт свет — в любое время суток за + границей тьма; на воксель ниже верха земли, чтобы не спорить по z). + Область «парит во тьме», край карты читается. +- **Деревья выше и крупнее** (`mapTreeModel`): сетка 10×10 → 12×12 (крона + до r≈5 может нависать над соседний тайл — blocked-семантика по тайлам + не меняется), стволы 22–36 (в 1.4–2.2 раза выше героя 16). **Высота + чанка мира 52 → 64** — дерево до 53 вокселей больше не влезало в 52. +- **Солнце едет по часам**: движок умел (`setSun(k)` — азимут восток→запад, + высота по синусу), но игра НИКОГДА не вызывала setSun — солнце висело на + полуденном k=0.5, тени стояли. Теперь `GameLighting.update()` зовёт + `setSun((часы−6)/12)` каждый тик; снапшот моста дополнился `sunK`. + Грабля: **гейт детерминизма областей упал** — солнце едет каждую + игровую минуту, а кадры для «пересбор = тот же кадр» снимались в разные + минуты; фикс гейта — пин `setClock(479)` перед каждым кадром. +- **Фонарик героя**: + - движок: `addSpotLight` (`SpotLightOptions/Handle`, GAIN общий с + точечными, цель луча — `setTarget` в мировой точке) + + `flashlightModel` (2×2×5 вдоль +Z, линза впереди); + - игра: крепление `flashlight` (`bone 'armL'`, pos [0.5,5,0] — зеркально + факелу), луч в `GameLighting` — конус по `hero.yaw` в тёмных областях + и ночью (условие лампы), мягкое мерцание; снапшот — `flashlight.on/intensity`; + - гейты: gear (110 вокс по умолчанию), atmosphere («фонарик днём + погашен», «ночь: фонарик светит», «солнце движется: утро < полдень < + вечер»). +- Визуально — одноразовая проба `tools/probe-visual.mjs` (ночь с лучом, + край карты днём, крупные деревья) — НЕ коммитится. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index 536c085..adfe720 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -64,40 +64,41 @@ // ---- Генераторы контента игры (деревья/кусты/валуны в тайл 10×10, дома...) ---- -/** Дерево карты: высокое (в ~2.3 раза выше героя — 16), footprint тайла - * 10×10 (крона не вылезает на соседей — blocked-семантика v1). Крона - * с профилем: низ вздут, к верху сходит на нет; рваный край. +/** Дерево карты: высокое (ствол 22–36 — в 1.4–2.2 раза выше героя — 16), + * footprint сетки 12×12 (крона может чуть нависать над соседний тайл — + * 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(20, 24) : rng.range(16, 21); - const crownH = variant === 2 ? rng.range(14, 17) : rng.range(11, 15); - const g = new VoxelGrid(10, trunkH + crownH + 1, 10); + 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); + const g = new VoxelGrid(12, trunkH + crownH + 1, 12); for (let y = 0; y < trunkH; y++) { // ствол 2×2 — на этом масштабе 1×1 — щепка - g.set(4, y, 4, 4); g.set(5, y, 4, 4); - g.set(4, y, 5, 4); g.set(5, y, 5, 4); + 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 = 3.6 + rng.next() * 0.9; // джиттер радиуса фиксируем заранее + const r = 4.2 + rng.next() * 1.1; // джиттер радиуса фиксируем заранее for (let dy = 0; dy < crownH; dy++) { const y = trunkH - 1 + dy; const t = dy / crownH; // 0..1 по высоте кроны - for (let dz = -4; dz <= 4; dz++) - for (let dx = -4; dx <= 4; dx++) { + for (let dz = -5; dz <= 5; dz++) + for (let dx = -5; dx <= 5; dx++) { const d2 = dx * dx + dz * dz; let ok: boolean; if (variant === 0) { const rdy = r * (1.15 - t * 0.9); // низ вздут, верх сходит на нет ok = d2 <= rdy * rdy + 1; } else if (variant === 1) { - ok = d2 <= (t > 0.6 ? 3 : 6); // колонна: к верху уже + ok = d2 <= (t > 0.6 ? 5 : 9); // колонна: к верху уже } else { // ель: ярусы — каждый третий слой шире (ступень), выше уже - ok = d2 <= (dy % 3 === 0 ? 16 : t < 0.5 ? 9 : 4); + ok = d2 <= (dy % 3 === 0 ? 24 : t < 0.5 ? 13 : 6); } if (!ok) continue; - if (dy > 0 && d2 > 8 && !rng.chance(0.7)) continue; // рваный край + if (dy > 0 && d2 > 12 && !rng.chance(0.7)) continue; // рваный край const light = dy === crownH - 1 || rng.chance(0.25); - g.set(5 + dx, y, 5 + dz, light ? 5 : 3); + g.set(6 + dx, y, 6 + dz, light ? 5 : 3); } } return encodeModel(g, { 3: '#626c4c', 5: '#7d8662', 4: '#5c3b2e' }); @@ -536,7 +537,7 @@ */ export function buildAreaWorld(l: AreaLayout): VoxelWorld { registerWorldKinds(); - const world = new VoxelWorld(52); // высота чанка 52 (башня до 47 + запас) + const world = new VoxelWorld(64); // высота чанка 64 (дерево до 53, башня 47 + запас) const tiles = rleDecode(l.tiles, l.width * l.height); // 1) земля (все тайлы) — потом декор, потом штампы (объекты поверх) for (let ty = 0; ty < l.height; ty++) diff --git a/v2/apps/game/src/lighting.ts b/v2/apps/game/src/lighting.ts index da23908..c0a0290 100644 --- a/v2/apps/game/src/lighting.ts +++ b/v2/apps/game/src/lighting.ts @@ -6,7 +6,7 @@ * детерминированный flickerFactor от времени цикла. */ import { dayNightAmbient, dayNightFactor, flickerFactor } from '@rpg/engine'; -import type { PointLightHandle, VoxelRenderer } 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'; @@ -18,6 +18,18 @@ flicker: number; } +/** Фонарик героя: конус света по направлению взгляда (addSpotLight). */ +export interface FlashlightDef { + color: number; + intensity: number; + /** Дальность луча в вокселях. */ + distance: number; + /** Полный угол конуса (рад). */ + angle: number; + /** Мягкость края конуса 0..1. */ + penumbra: number; +} + /** Кап активных источников — защита от опечатки в данных (tiles одного id). */ export const MAX_LIGHTS = 24; @@ -58,18 +70,30 @@ timeSeconds: () => number; /** Лампа героя (v1 HERO_LAMP); null — без лампы. */ lamp?: HeroLampDef | null; + /** Фонарик героя (луч по направлению взгляда); null — нет. */ + flashlight?: FlashlightDef | null; + /** Куда смотрит герой (yaw, рад; 0 — +Z) — направление луча. */ + heroFacing: () => number; } -/** Снапшот для моста: фактор ночи + активные источники (пост-мерцание). */ +/** Снапшот для моста: фактор ночи, путь солнца + активные источники. */ export interface LightingSnapshot { nightFactor: number; + /** Путь солнца по дню 0..1 (тени следуют за временем суток). */ + sunK: number; sources: { id: string; x: number; y: number; intensity: number }[]; + /** Фонарик: горит ли и с какой яркостью (пост-мерцание). */ + flashlight: { on: boolean; intensity: number }; } export class GameLighting { private readonly deps: GameLightingDeps; /** Активные источники прошлого тика (diff: снять погасшие по флагам). */ private readonly handles = new Map(); + /** Прожектор фонарика (создаётся лениво, гаснет — dispose). */ + private spot: SpotLightHandle | null = null; + /** Интенсивность луча прошлого тика (снапшот — пост-мерцание). */ + private spotIntensity = 0; constructor(deps: GameLightingDeps) { this.deps = deps; @@ -90,6 +114,11 @@ return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000; } + /** Путь солнца по дню 0..1: 6:00 → 0, 12:00 → 0.5, 18:00 → 1. */ + private sunK(): number { + return Math.min(1, Math.max(0, (this.deps.timeHours() - 6) / 12)); + } + /** Тик: ночная цель ambient + источники (diff по id, мерцание). */ update(): void { const light = this.deps.lighting; @@ -100,6 +129,10 @@ ? dayNightAmbient(light.ambient, light.nightAmbient, k, light.duskColor) : light?.ambient ?? 0x94949e; this.deps.renderer.setNight(k, tint); + // Солнце едет по часам (6:00 восход → 12:00 зенит → 18:00 закат): + // тени меняют направление и длину в течение дня (setSun сам гасит + // свет ночью — до и после окна клампимся в [0..1]). + this.deps.renderer.setSun(this.sunK()); const next = new Set(); for (const def of this.activeDefs(k)) { @@ -135,12 +168,51 @@ this.handles.delete(key); } } + this.updateFlashlight(k); + } + + /** + * Фонарик: конус по направлению взгляда героя (как лампа — в тёмных + * областях и ночью). Луч — лениво созданный SpotLight: каждый тик + * позиция/цель/яркость (мягкое мерцание — живость), погас — dispose. + */ + private updateFlashlight(night: number): void { + const fl = this.deps.flashlight; + const on = fl !== undefined && fl !== null + && (isDarkAmbient(this.deps.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR); + if (!fl || !on) { + if (this.spot) { + this.spot.dispose(); + this.spot = null; + } + this.spotIntensity = 0; + return; + } + 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 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; + if (this.spot) { + this.spot.setPos(origin); + this.spot.setTarget(hit); + this.spot.setIntensity(this.spotIntensity); + } else { + this.spot = this.deps.renderer.addSpotLight({ + pos: origin, color: fl.color, intensity: this.spotIntensity, + distance: fl.distance * 1.2, angle: fl.angle, penumbra: fl.penumbra, + }); + this.spot.setTarget(hit); + } } /** Состояние для агентного снапшота (интенсивность — пост-мерцание). */ snapshot(): LightingSnapshot { return { nightFactor: this.nightFactor, + sunK: Math.round(this.sunK() * 1000) / 1000, + flashlight: { on: this.spot !== null, intensity: Math.round(this.spotIntensity * 1000) / 1000 }, sources: [...this.handles.entries()].map(([key, entry]) => { const [id, at] = key.split('@'); const [tx, ty] = at ? at.split(',').map(Number) : [null, null]; @@ -157,6 +229,8 @@ destroy(): void { for (const h of this.handles.values()) h.handle.dispose(); this.handles.clear(); + this.spot?.dispose(); + this.spot = null; } // --- внутреннее --- diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 8050564..a2ca551 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -10,7 +10,7 @@ VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, Inventory, CutsceneRunner, validateCutscenes, - resolveTiles, collides, worldTileAtlas, backpackModel, torchModel, + resolveTiles, collides, worldTileAtlas, backpackModel, torchModel, flashlightModel, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, @@ -33,7 +33,7 @@ import { FlowerQuest } from './flowers'; import { GameClock } from './clock'; import { LIGHTING, ATMOSPHERE, AIRFX, type Atmosphere, type LightingDef } from './data/lighting'; -import { GameLighting, type HeroLampDef } from './lighting'; +import { GameLighting, type HeroLampDef, type FlashlightDef } from './lighting'; import { AshParticles } from './ash'; import { AmbientParticles, type AmbientKind } from './ambientParticles'; import { WaterShimmer, type WaterCell } from './waterShimmer'; @@ -77,6 +77,16 @@ // ---- Рендер: мир области (пересоздаётся на переходе), меш на чанк ---- const canvas = document.getElementById('scene') as HTMLCanvasElement; 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; +renderer.scene.add(abyss); // ---- Катсцены: данные + движковый рантайм (камера — инъекция рендера) ---- const cutsceneActions: Record = { @@ -164,8 +174,9 @@ // ---- Снаряжение героя (трек 32, демо механизма креплений): рюкзак за // спиной на корне (едет с покачиванием шага), факел в правой кисти -// (машет с рукой). Точки — в связочном пространстве манекена 10×16×4: -// торс x2..7/y6..11 (спина z1), кисть руки ≈ [8.5, 6, 2.5]. ---- +// (машет с рукой), фонарик в левой (трек 33 — луч даёт addSpotLight). +// Точки — в связочном пространстве манекена 10×16×4: торс x2..7/y6..11 +// (спина z1), кисти рук ≈ [8.5 / 0.5, 5, 2]. ---- const GEAR_VARIANTS = { backpack: (): Attachment => ({ bone: 'root', model: backpackModel(), pos: [2, 5, -1], @@ -173,12 +184,16 @@ torch: (): Attachment => ({ bone: 'armR', model: torchModel(), pos: [8.5, 5, 2], }), + flashlight: (): Attachment => ({ + bone: 'armL', model: flashlightModel(), pos: [0.5, 5, 0], + }), } as const; export type GearId = keyof typeof GEAR_VARIANTS; // Снаряжение — в каталоге дев-вьювера (модели без риги, слой тайлей — если есть) for (const [id, label, make] of [ ['gear:backpack', 'Рюкзак', backpackModel], ['gear:torch', 'Факел', torchModel], + ['gear:flashlight', 'Фонарик', flashlightModel], ] as const) { registerObject({ id, label, @@ -188,7 +203,7 @@ }, }); } -let gear: GearId[] = ['backpack', 'torch']; +let gear: GearId[] = ['backpack', 'torch', 'flashlight']; const applyGear = (): void => hero.setAttachments(gear.map((g) => GEAR_VARIANTS[g]())); applyGear(); const heroCloud = renderer.addVoxelCloud(hero.visual(), resolveTiles(MAN)); @@ -205,6 +220,10 @@ // ---- Освещение и атмосфера (трек 18): области задают тон и источники ---- const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 }; +/** Фонарик героя: тёплый конус по взгляду (луч — GameLighting в тьме/ночь). */ +const FLASHLIGHT: FlashlightDef = { + color: 0xffedc0, intensity: 2.4, distance: 110, angle: 0.55, penumbra: 0.45, +}; let lighting: GameLighting | null = null; const ash = new AshParticles(renderer); /** Глобальный вкл/выкл пепла (мост: гейт кадр-равенства). */ @@ -463,10 +482,12 @@ lighting: LIGHTING[currentArea.id] as LightingDef | undefined, layerTilePos: layerPos, heroPos: () => ({ x: hero.body.pos[0], z: hero.body.pos[2] }), + heroFacing: () => hero.yaw, hasFlag: (f) => state.hasFlag(f), timeHours: () => clock.hours, timeSeconds: () => elapsed, lamp: HERO_LAMP, + flashlight: FLASHLIGHT, }); areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется @@ -1234,7 +1255,7 @@ }, /** Снапшот освещения: ночь + активные источники (пост-мерцание). */ lightingInfo(): ReturnType { - return lighting?.snapshot() ?? { nightFactor: 0, sources: [] }; + return lighting?.snapshot() ?? { nightFactor: 0, sunK: 0, sources: [], flashlight: { on: false, intensity: 0 } }; }, /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды/воздух (гейт). */ ambientInfo() { diff --git a/v2/apps/game/tools/checks/areas.mjs b/v2/apps/game/tools/checks/areas.mjs index f99c30b..ed6c2de 100644 --- a/v2/apps/game/tools/checks/areas.mjs +++ b/v2/apps/game/tools/checks/areas.mjs @@ -101,10 +101,14 @@ // Детерминизм сборки: пересбор той же области — тот же кадр const spawnC = center({ x: 16, y: 28 }); await client.call('setFollow', false); + // часы пин перед каждым кадром: солнце едет по игровым минутам — + // кадры равны только при одном и том же времени суток + await client.call('setClock', 479); await client.call('stepTicks', 1); // отрисовать кадр с фиксированной камерой (следящая — на float-цели) const shotA = await client.screenshot('/tmp/v2_areas_meadows.png'); await client.call('gotoArea', 'meadows'); - await client.call('stepTicks', 5); + await client.call('setClock', 479); + await client.call('stepTicks', 1); const shotA2 = await client.screenshot('/tmp/v2_areas_meadows2.png'); const detDiff = countDiff(decodePng(readFileSync(shotA)).rgba, decodePng(readFileSync(shotA2)).rgba); check('пересборка области детерминирована (кадры равны)', detDiff === 0, { detDiff }); diff --git a/v2/apps/game/tools/checks/atmosphere.mjs b/v2/apps/game/tools/checks/atmosphere.mjs index 59dfac4..e150c14 100644 --- a/v2/apps/game/tools/checks/atmosphere.mjs +++ b/v2/apps/game/tools/checks/atmosphere.mjs @@ -91,12 +91,25 @@ await client.call('setClock', 480); // назад в день let li = await client.call('lightingInfo'); check('день на лугах: источников нет (bells whenNight)', li.sources.length === 0, li); + check('фонарик днём погашен', li.flashlight.on === false, li.flashlight); + + // ---- Солнце едет по часам (тени следуют за временем суток) ---- + const sunNoon = li.sunK; + await client.call('setClock', 420); // 7:00 — утро + const sunMorning = (await client.call('lightingInfo')).sunK; + await client.call('setClock', 1080); // 18:00 — вечер + const sunEvening = (await client.call('lightingInfo')).sunK; + check('солнце движется: утро < полдень < вечер', + sunMorning < sunNoon && sunNoon < sunEvening, + { morning: sunMorning, noon: sunNoon, evening: sunEvening }); + await client.call('setClock', 480); await client.call('setClock', 1200); // ночь li = await client.call('lightingInfo'); check('ночь на лугах: колокольчики светятся (bells@тайлы)', li.sources.some((s) => s.id === 'bells' && s.intensity > 0), li); check('ночь: лампа героя у героя', li.sources.some((s) => s.id === 'hero_lamp'), li); + check('ночь: фонарик героя светит', li.flashlight.on === true && li.flashlight.intensity > 0, li.flashlight); // Мерцание: интенсивность bells меняется со временем (flicker 0.15) const i0 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1; diff --git a/v2/apps/game/tools/checks/gear.mjs b/v2/apps/game/tools/checks/gear.mjs index bb91987..a23a5b2 100644 --- a/v2/apps/game/tools/checks/gear.mjs +++ b/v2/apps/game/tools/checks/gear.mjs @@ -40,11 +40,12 @@ await client.call('setFollow', false); // камера фикс — диф только от снаряжения await client.call('stepTicks', 5); - // ---- Снаряжение по умолчанию: рюкзак + факел ---- + // ---- Снаряжение по умолчанию: рюкзак + факел + фонарик ---- const hi = await client.call('heroInfo'); - // рюкзак 6×6×2 (72) + клапан 6×2 (12) = 84 (пряжка — перекраска); факел 6 - check('герой по умолчанию: рюкзак + факел (90 вокс)', - hi.gear.includes('backpack') && hi.gear.includes('torch') && hi.gearVoxels === 90, hi.gear); + // рюкзак 84 (пряжка — перекраска); факел 6; фонарик 2×2×5 = 20 → 110 + check('герой по умолчанию: рюкзак + факел + фонарик (110 вокс)', + hi.gear.includes('backpack') && hi.gear.includes('torch') && hi.gear.includes('flashlight') + && hi.gearVoxels === 110, hi.gear); await client.call('stepTicks', 1); const shotOn = await client.screenshot('/tmp/v2_gear_on.png'); @@ -56,14 +57,14 @@ const shotOff = await client.screenshot('/tmp/v2_gear_off.png'); const diff = countDiff(decodePng(readFileSync(shotOn)).rgba, decodePng(readFileSync(shotOff)).rgba); check('снаряжение меняет кадр', diff > 100, { diff }); - await client.call('heroGear', ['backpack', 'torch']); + await client.call('heroGear', ['backpack', 'torch', 'flashlight']); await client.call('stepTicks', 1); - // ---- Только рюкзак: 84 вокс (факел — 6) ---- + // ---- Только рюкзак: 84 вокс (факел — 6, фонарик — 20) ---- await client.call('heroGear', ['backpack']); const hiB = await client.call('heroInfo'); check('только рюкзак: 84 вокс', hiB.gearVoxels === 84, hiB.gear); - await client.call('heroGear', ['backpack', 'torch']); + await client.call('heroGear', ['backpack', 'torch', 'flashlight']); // ---- Ходьба со снаряжением: герой ходит (механизм не ломает физику) ---- await client.call('heroTeleport', 40, 60); diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 06ac445..c80c3a9 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -35,7 +35,7 @@ export { Rng } from './math/rng'; export { encodeModel, decodeModel, validateModel, assertModelValid, texIdAt, texBytes, resolveTiles } from './models/format'; export type { VoxelModel, VoxelTile, ModelTexture, ResolvedTile } from './models/format'; -export { treeModel, boulderModel, backpackModel, torchModel } from './models/generators'; +export { treeModel, boulderModel, backpackModel, torchModel, flashlightModel } from './models/generators'; export type { Variant, GearPalette } from './models/generators'; export { stampModel } from './models/stamp'; export type { StampClip, StampOptions } from './models/stamp'; @@ -106,5 +106,5 @@ dayNightAmbient, dayNightFactor, dimColor, flickerFactor, lerpAmbient, lerpColor, } from './render/daynight'; export type { DayNightSpec } from './render/daynight'; -export type { PointLightOptions, PointLightHandle } from './render/voxelRenderer'; -// setDayAmbient/setNight/addPointLight — методы класса VoxelRenderer +export type { PointLightOptions, PointLightHandle, SpotLightOptions, SpotLightHandle } from './render/voxelRenderer'; +// setDayAmbient/setNight/addPointLight/addSpotLight — методы класса VoxelRenderer diff --git a/v2/packages/engine/src/models/generators.ts b/v2/packages/engine/src/models/generators.ts index 5fb8454..88fd915 100644 --- a/v2/packages/engine/src/models/generators.ts +++ b/v2/packages/engine/src/models/generators.ts @@ -143,3 +143,19 @@ g.set(0, 5, 0, 6); // ядро пламени return encodeModel(g, { 2: c.strap!, 4: c.wood!, 5: c.flame!, 6: c.flameCore! }); } + +/** + * Фонарик: ручной цилиндр 2×2×5 вдоль +Z (хвост у кисти, линза светится + * впереди — торчит за фасад руки); угол-origine — левый нижний задний угол. + * На манекене ставится в левую кисть (attach pos ≈ [0.5, 5, 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++) + 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); // линза + return encodeModel(g, { 1: c.strap!, 2: c.flameCore!, 3: c.metal! }); +} diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index 1e2d5ab..a7dfef8 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -45,6 +45,30 @@ dispose(): void; } +/** Параметры прожектора (фонарик героя): конус света вперёд. */ +export interface SpotLightOptions { + /** Позиция в мировых единицах. */ + pos: [number, number, number]; + color: number; + /** Яркость 0..~1.5 (множитель движка, усиливается POINT_LIGHT_GAIN). */ + intensity: number; + /** Дальность луча в мировых единицах. */ + distance: number; + /** Полный угол конуса (рад). */ + angle: number; + /** Мягкость края конуса 0..1 (по умолчанию 0.4). */ + penumbra?: number; +} + +/** Ручка прожектора: игра двигает источник/цель и меняет яркость сама. */ +export interface SpotLightHandle { + setIntensity(intensity: number): void; + setPos(pos: [number, number, number]): void; + /** Направить луч в мировую точку (цель — позиция + направление). */ + setTarget(p: [number, number, number]): void; + dispose(): void; +} + /** Усиление интенсивности точечных источников: three r155+ считает PointLight * в физических единицах (I/dist^decay) — при decay 1.6 на дистанции 5 вокселей * от игрового множителя ~0.9 остаётся ~0.07 (свет невиден). GAIN применяет @@ -195,6 +219,35 @@ }; } + /** Прожектор (не бросает тень): конус света — фонарик героя. Яркость + * усиливается POINT_LIGHT_GAIN (как у точечных, см. константу). Цель + * луча — свой объект сцены: setTarget двигает её, свет поворачивается. */ + addSpotLight(opts: SpotLightOptions): SpotLightHandle { + const light = new THREE.SpotLight( + opts.color, opts.intensity * POINT_LIGHT_GAIN, opts.distance, + opts.angle, opts.penumbra ?? 0.4, 1.0, + ); + light.position.set(...opts.pos); + this.scene.add(light); + this.scene.add(light.target); + return { + setIntensity: (i) => { + light.intensity = i * POINT_LIGHT_GAIN; + }, + setPos: (p) => { + light.position.set(p[0], p[1], p[2]); + }, + setTarget: (p) => { + light.target.position.set(p[0], p[1], p[2]); + }, + dispose: () => { + this.scene.remove(light); + this.scene.remove(light.target); + light.dispose(); + }, + }; + } + /** Материал вокселей: вершинные цвета (палитра × AO) + ламберт от солнца. */ static material(): THREE.MeshLambertMaterial { return new THREE.MeshLambertMaterial({ vertexColors: true });