diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6c6fa09..9425eb7 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -38,13 +38,26 @@ isoToScreen, screenToIso, screenToIsoExact, + worldToScreen, + screenToWorld, + unitsToPx, + pxToUnits, + worldLen, + worldNorm, + worldDist, + moveTowardsW, + tileToWorld, + worldToTile, + worldRectToScreen, DEFAULT_ISO, type IsoLayout } from './math/iso'; export { createRng, type Rng } from './math/rng'; export { inCircle, + inCircleW, inCone, + inConeW, angleBetween, nearest } from './math/shapes'; diff --git a/packages/engine/src/math/__tests__/shapes.test.ts b/packages/engine/src/math/__tests__/shapes.test.ts index b188f2c..fce3a84 100644 --- a/packages/engine/src/math/__tests__/shapes.test.ts +++ b/packages/engine/src/math/__tests__/shapes.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { inCircle, inCone, angleBetween, nearest } from '../shapes'; +import { inCircle, inCircleW, inCone, inConeW, angleBetween, nearest } from '../shapes'; +import { worldToScreen } from '../iso'; describe('inCircle', () => { it('точка внутри и на границе', () => { @@ -84,4 +85,39 @@ it('пустой список — null', () => { expect(nearest([], { x: 0, y: 0 })).toBeNull(); }); +}); + +describe('W-варианты (мировые юниты)', () => { + const iso = { tileW: 32, tileH: 16, originX: 0, originY: 0 }; + + it('inCircleW эквивалентен проекции в экранные px', () => { + const center = { x: 3, y: 1 }; + for (let wx = 0; wx <= 6; wx += 0.5) { + for (let wy = 0; wy <= 2; wy += 0.5) { + const point = { x: wx, y: wy }; + expect(inCircleW(center, 1.5, point, iso)).toBe( + inCircle(worldToScreen(center.x, center.y, iso), 48, worldToScreen(point.x, point.y, iso)) + ); + } + } + }); + + it('inConeW эквивалентен проекции в экранные px', () => { + const from = { x: 3, y: 1 }; + const dir = { x: 1, y: -1 }; // «вправо» в мировых юнитах + for (let wx = 0; wx <= 6; wx += 0.5) { + for (let wy = 0; wy <= 2; wy += 0.5) { + const point = { x: wx, y: wy }; + expect(inConeW(from, dir, 2, Math.PI / 3, point, iso)).toBe( + inCone( + worldToScreen(from.x, from.y, iso), + worldToScreen(dir.x, dir.y, iso), + 64, + Math.PI / 3, + worldToScreen(point.x, point.y, iso) + ) + ); + } + } + }); }); \ No newline at end of file diff --git a/packages/engine/src/math/__tests__/world.test.ts b/packages/engine/src/math/__tests__/world.test.ts new file mode 100644 index 0000000..dd8e227 --- /dev/null +++ b/packages/engine/src/math/__tests__/world.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import type { Vec2 } from '../Vec2'; +import { + worldToScreen, + screenToWorld, + unitsToPx, + pxToUnits, + worldLen, + worldNorm, + worldDist, + moveTowardsW, + tileToWorld, + worldToTile, + worldRectToScreen, + DEFAULT_ISO +} from '../iso'; + +describe('проекция мир↔экран', () => { + it('совпадает с iso-формулой', () => { + // Центр тайла (2, 3) на экране: (2.5-3.5)*16, (2.5+3.5)*8 + const s = worldToScreen(2.5, 3.5); + expect(s.x).toBeCloseTo(-16, 10); + expect(s.y).toBeCloseTo(48, 10); + }); + + it('round-trip worldToScreen ∘ screenToWorld', () => { + for (let wx = -5; wx <= 5; wx += 0.7) { + for (let wy = -5; wy <= 5; wy += 0.7) { + const s = worldToScreen(wx, wy); + const back = screenToWorld(s.x, s.y); + expect(back.x).toBeCloseTo(wx, 10); + expect(back.y).toBeCloseTo(wy, 10); + } + } + }); + + it('учитывает origin', () => { + const iso = { ...DEFAULT_ISO, originX: 100, originY: 50 }; + const s = worldToScreen(1, 0, iso); + expect(s.x).toBeCloseTo(100 + 16, 10); + expect(s.y).toBeCloseTo(50 + 8, 10); + }); +}); + +describe('скаляры: юниты↔пиксели', () => { + it('линейка проекции — tileW px на юнит', () => { + expect(unitsToPx(1)).toBe(32); + expect(unitsToPx(1.5)).toBe(48); + expect(pxToUnits(32)).toBe(1); + expect(pxToUnits(48)).toBe(1.5); + expect(pxToUnits(unitsToPx(2.75))).toBeCloseTo(2.75, 10); + }); +}); + +describe('метрика проекции', () => { + it('длины характерных смещений', () => { + // Экранные оси — мировые диагонали: «вправо» = (1,-1) -> 1 юнит. + expect(worldLen(1, -1)).toBeCloseTo(1, 10); + expect(worldLen(-1, 1)).toBeCloseTo(1, 10); + // Диагональ сетки (1,1) — вертикальный шаг: на экране 8+8=16 px -> 0.5 юнита. + expect(worldLen(1, 1)).toBeCloseTo(0.5, 10); + // По оси сетки: соседние тайлы — sqrt(16² + 8²)/32 ≈ 0.56 юнита. + expect(worldLen(1, 0)).toBeCloseTo(Math.hypot(16, 8) / 32, 10); + }); + + it('worldDist совпадает с worldLen', () => { + expect(worldDist({ x: 0, y: 0 }, { x: 3, y: -3 })).toBeCloseTo(3, 10); + expect(worldDist({ x: 1, y: 2 }, { x: 1, y: 2 })).toBeCloseTo(0, 10); + }); + + it('worldNorm даёт постоянную экранную скорость', () => { + // Нормализованный вектор в юнитах при скорости s юнит/с даёт ровно 32·s px/с. + const s = 1.75; + const dirs: Vec2[] = [ + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: -1, y: 1 }, + { x: 2, y: -1 } + ]; + for (const d of dirs) { + const n = worldNorm(d.x, d.y); + const step = { x: n.x * s, y: n.y * s }; + const px = worldToScreen(step.x, step.y); + expect(Math.hypot(px.x, px.y)).toBeCloseTo(32 * s, 6); + } + }); + + it('worldNorm нулевого вектора', () => { + expect(worldNorm(0, 0)).toEqual({ x: 0, y: 0 }); + }); +}); + +describe('moveTowardsW', () => { + const a = { x: 0, y: 0 }; + const b = { x: 3, y: -3 }; // 3 юнита + + it('не проскакивает цель', () => { + expect(moveTowardsW(a, b, 10)).toEqual(b); + }); + + it('постоянная экранная скорость на любом направлении', () => { + const step = moveTowardsW(a, b, 1.75); + const px = worldToScreen(step.x, step.y); + expect(Math.hypot(px.x, px.y)).toBeCloseTo(32 * 1.75, 6); + }); + + it('последовательность шагов достигает цели', () => { + let p = a; + for (let i = 0; i < 100 && p !== b; i++) { + const next = moveTowardsW(p, b, 1.75); + if (next === p) break; + p = next; + } + expect(p).toEqual(b); + }); +}); + +describe('тайлы↔мир', () => { + it('tileToWorld — центр тайла', () => { + expect(tileToWorld(2, 3)).toEqual({ x: 2.5, y: 3.5 }); + }); + + it('worldToTile — floor внутри карты', () => { + expect(worldToTile(2.5, 3.5, 10, 10)).toEqual({ x: 2, y: 3 }); + expect(worldToTile(0, 0, 10, 10)).toEqual({ x: 0, y: 0 }); + }); + + it('worldToTile вне карты -> null', () => { + expect(worldToTile(-0.1, 5, 10, 10)).toBeNull(); + expect(worldToTile(5, 10, 10, 10)).toBeNull(); + }); +}); + +describe('worldRectToScreen', () => { + it('карта 28x28 — границы от западного угла', () => { + const r = worldRectToScreen(0, 0, 28, 28); + expect(r).toEqual({ x: -448, y: 0, width: 896, height: 448 }); + }); + + it('произвольный прямоугольник', () => { + // Углы (2,0) и (2,1): x от (2-1)*16 до 2*16. + const r = worldRectToScreen(2, 0, 1, 1); + expect(r.x).toBeCloseTo((2 - 1) * 16, 10); + expect(r.width).toBeCloseTo(2 * 16, 10); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/math/iso.ts b/packages/engine/src/math/iso.ts index b81887a..60d345c 100644 --- a/packages/engine/src/math/iso.ts +++ b/packages/engine/src/math/iso.ts @@ -13,7 +13,7 @@ */ export interface IsoLayout { - /** Ширина ромба тайла (пиксели, чётное). */ + /** Ширина ромба тайла (пиксели, чётное) — проекция 1 мирового юнита. */ tileW: number; /** Высота ромба тайла (пиксели, tileW / 2). */ tileH: number; @@ -69,4 +69,108 @@ } } return null; +} + +// ---------- мировые юниты ---------- +// +// Позиции хранятся в мировых юнитах (1 юнит = 1 тайл), float в мировой плоскости. +// Экран — виртуальные пиксели; перевод мир→экран — проекция, округление только +// на границе отрисовки (Camera.apply). Две «линейки»: +// • точка (позиция) — анизотропная проекция: tileW/2 и tileH/2 px на юнит; +// • скаляр (дистанция, радиус, скорость, высота) — линейка проекции: tileW px на юнит. + +import type { Vec2 } from './Vec2'; + +/** Мировая позиция → экранные пиксели (float, без округления). */ +export function worldToScreen(wx: number, wy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + return { + x: (wx - wy) * (iso.tileW / 2) + iso.originX, + y: (wx + wy) * (iso.tileH / 2) + iso.originY + }; +} + +/** Экранные пиксели → мировая позиция (float). */ +export function screenToWorld(sx: number, sy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + const halfW = iso.tileW / 2; + const halfH = iso.tileH / 2; + const dx = (sx - iso.originX) / halfW; + const dy = (sy - iso.originY) / halfH; + return { x: (dx + dy) / 2, y: (dy - dx) / 2 }; +} + +/** Скаляр (дистанция, скорость…) из юнитов в пиксели. */ +export function unitsToPx(units: number, iso: IsoLayout = DEFAULT_ISO): number { + return units * iso.tileW; +} + +/** Скаляр из пикселей в юниты. */ +export function pxToUnits(px: number, iso: IsoLayout = DEFAULT_ISO): number { + return px / iso.tileW; +} + +/** Длина смещения в юнитах по «метрике проекции»: |M(d)| / tileW. */ +export function worldLen(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): number { + const sx = (dx - dy) * (iso.tileW / 2); + const sy = (dx + dy) * (iso.tileH / 2); + return Math.hypot(sx, sy) / iso.tileW; +} + +/** Нормализованный мировой вектор (нулевой -> {0, 0}). */ +export function worldNorm(dx: number, dy: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + const l = worldLen(dx, dy, iso); + if (l < 1e-8) return { x: 0, y: 0 }; + return { x: dx / l, y: dy / l }; +} + +/** Расстояние между точками в юнитах по метрике проекции. */ +export function worldDist(a: Vec2, b: Vec2, iso: IsoLayout = DEFAULT_ISO): number { + return worldLen(b.x - a.x, b.y - a.y, iso); +} + +/** Шаг из a в сторону b на step юнитов (аналог moveTo в мировых юнитах). */ +export function moveTowardsW(a: Vec2, b: Vec2, step: number, iso: IsoLayout = DEFAULT_ISO): Vec2 { + const dx = b.x - a.x; + const dy = b.y - a.y; + const l = worldLen(dx, dy, iso); + if (l <= step || l < 1e-8) return { x: b.x, y: b.y }; + const k = step / l; + return { x: a.x + dx * k, y: a.y + dy * k }; +} + +/** Центр тайла в мировых юнитах. */ +export function tileToWorld(tx: number, ty: number): Vec2 { + return { x: tx + 0.5, y: ty + 0.5 }; +} + +/** Мировая позиция -> тайл (floor + границы карты); вне карты -> null. */ +export function worldToTile( + wx: number, + wy: number, + mapW: number, + mapH: number +): { x: number; y: number } | null { + const tx = Math.floor(wx); + const ty = Math.floor(wy); + if (tx < 0 || ty < 0 || tx >= mapW || ty >= mapH) return null; + return { x: tx, y: ty }; +} + +/** Мировой прямоугольник -> экранный bbox (для bounds камеры). */ +export function worldRectToScreen( + x: number, + y: number, + w: number, + h: number, + iso: IsoLayout = DEFAULT_ISO +): { x: number; y: number; width: number; height: number } { + const tl = worldToScreen(x, y, iso); + const hw = iso.tileW / 2; + const hh = iso.tileH / 2; + // Левый край bbox — у соседа-близнеца (x, y + h), правый — у (x + w, y). + return { + x: tl.x - h * hw, + y: tl.y, + width: (w + h) * hw, + height: (w + h) * hh + }; } \ No newline at end of file diff --git a/packages/engine/src/math/shapes.ts b/packages/engine/src/math/shapes.ts index 8ccc78f..6f0898f 100644 --- a/packages/engine/src/math/shapes.ts +++ b/packages/engine/src/math/shapes.ts @@ -4,6 +4,7 @@ */ import type { Vec2 } from './Vec2'; +import { DEFAULT_ISO, type IsoLayout, worldToScreen } from './iso'; /** Точка внутри круга (включая границу). */ export function inCircle(center: Vec2, radius: number, point: Vec2): boolean { @@ -40,6 +41,36 @@ return angleBetween(dir, { x: dx, y: dy }) <= halfAngle; } +// ---------- мировые юниты: метризованные варианты ---------- + +/** Точка внутри круга; позиции и радиус — в мировых юнитах. */ +export function inCircleW( + center: Vec2, + radius: number, + point: Vec2, + iso: IsoLayout = DEFAULT_ISO +): boolean { + const c = worldToScreen(center.x, center.y, iso); + const p = worldToScreen(point.x, point.y, iso); + return inCircle(c, radius * iso.tileW, p); +} + +/** Точка внутри конуса; позиции, range и dir — в мировых юнитах (dir проецируется). */ +export function inConeW( + from: Vec2, + dir: Vec2, + range: number, + halfAngle: number, + point: Vec2, + iso: IsoLayout = DEFAULT_ISO +): boolean { + const f = worldToScreen(from.x, from.y, iso); + const p = worldToScreen(point.x, point.y, iso); + // Углы меняются под анизотропной проекцией — меряем их в экранном пространстве. + const d = worldToScreen(dir.x, dir.y, iso); + return inCone(f, d, range * iso.tileW, halfAngle, p); +} + /** Ближайшая к from точка из списка (в пределах maxRange включительно), или null. */ export function nearest(list: T[], from: Vec2, maxRange = Infinity): T | null { let best: T | null = null;