/**
* Изометрия 2:1 (классический «ромб»).
*
* Сетка — целочисленные координаты тайлов (tx, ty).
* Экран — виртуальные пиксели. Тайл рисуется ромбом шириной tileW и высотой tileH (2:1).
*
* Формулы:
* screenX = (tx - ty) * (tileW / 2)
* screenY = (tx + ty) * (tileH / 2)
* Обратная:
* tx = (screenX / (tileW/2) + screenY / (tileH/2)) / 2
* ty = (screenY / (tileH/2) - screenX / (tileW/2)) / 2
*/
export interface IsoLayout {
/** Ширина ромба тайла (пиксели, чётное) — проекция 1 мирового юнита. */
tileW: number;
/** Высота ромба тайла (пиксели, tileW / 2). */
tileH: number;
/** Экранные координаты верхнего левого угла нулевого тайла. */
originX: number;
originY: number;
}
/** Стандартный тайл 32x16. */
export const DEFAULT_ISO: IsoLayout = { tileW: 32, tileH: 16, originX: 0, originY: 0 };
export function isoToScreen(tx: number, ty: number, iso: IsoLayout = DEFAULT_ISO): { x: number; y: number } {
return {
x: Math.round((tx - ty) * (iso.tileW / 2) + iso.originX),
y: Math.round((tx + ty) * (iso.tileH / 2) + iso.originY)
};
}
export function screenToIso(sx: number, sy: number, iso: IsoLayout = DEFAULT_ISO): { x: number; y: number } {
const halfW = iso.tileW / 2;
const halfH = iso.tileH / 2;
const dx = (sx - iso.originX) / halfW;
const dy = (sy - iso.originY) / halfH;
return { x: Math.floor((dx + dy) / 2), y: Math.floor((dy - dx) / 2) };
}
/**
* Точное попадание внутрь ромба тайла (floor может дать соседний тайл на границе).
* Возвращает координаты тайла, ромб которого содержит точку, или null.
*/
export function screenToIsoExact(
sx: number,
sy: number,
mapW: number,
mapH: number,
iso: IsoLayout = DEFAULT_ISO
): { x: number; y: number } | null {
const cand = screenToIso(sx, sy, iso);
for (let dy = 0; dy <= 1; dy++) {
for (let dx = 0; dx <= 1; dx++) {
const tx = cand.x + dx;
const ty = cand.y + dy;
const c = isoToScreen(tx, ty, iso);
const rx = sx - c.x;
const ry = sy - c.y;
// Ромб: |x/halfW| + |y/halfH| <= 1
if (Math.abs(rx) / (iso.tileW / 2) + Math.abs(ry) / (iso.tileH / 2) <= 1) {
if (tx >= 0 && ty >= 0 && tx < mapW && ty < mapH) {
return { x: tx, y: ty };
}
return null;
}
}
}
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
};
}