Newer
Older
rpg / packages / engine / tools / frames.mjs
/**
 * Операции над кадрами (Uint8Array w×h×4 RGBA) — синтез производных кадров
 * из опорных: псевдо-walk-циклы, idle-кадры оживителей, зеркалирование.
 * Всё nearest, без сглаживания — пиксель-арт не мылится.
 * Модуль жанронезависим: знает только формат RGBA-кадра.
 */

/** Прозрачный ли пиксель (alpha 0). */
const isEmpty = (rgba, i) => rgba[i + 3] === 0;

/** Копия пикселя src[i] в dst[j] (или прозрачность, если src вне кадра). */
function blit(src, dst, w, h, sx, sy, dx, dy) {
    const j = (dy * w + dx) * 4;
    if (sx < 0 || sy < 0 || sx >= w || sy >= h) {
        dst[j + 3] = 0;
        return;
    }
    const i = (sy * w + sx) * 4;
    dst[j] = src[i];
    dst[j + 1] = src[i + 1];
    dst[j + 2] = src[i + 2];
    dst[j + 3] = src[i + 3];
}

/**
 * Сдвиг кадра на (dx, dy) px; пустота заполняется прозрачностью.
 * dx > 0 — вправо, dy > 0 — вниз.
 */
export function shiftFrame(src, w, h, dx, dy) {
    const out = new Uint8Array(w * h * 4);
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            blit(src, out, w, h, x - dx, y - dy, x, y);
        }
    }
    return out;
}

/**
 * Idle-кадр «парения»: тело поднимается на dy px (по умолчанию 1),
 * снизу открывается прозрачность — для спрайтов с якорем по низу.
 */
export function bobFrame(src, w, h, dy = 1) {
    return shiftFrame(src, w, h, 0, -dy);
}

/**
 * «Приседание»: весь спрайт уходит вниз на 1 px, нижний ряд срезается —
 * спрайт становится ниже при том же якоре. Дешёвый 2-й кадр idle для
 * спрайтов с полем прозрачности под ногами.
 */
export function squashFrame(src, w, h) {
    return shiftFrame(src, w, h, 0, 1);
}

/** Зеркалирование по горизонтали (кадр «вправо» → «влево»). */
export function mirrorFrame(src, w, h) {
    const out = new Uint8Array(w * h * 4);
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            blit(src, out, w, h, w - 1 - x, y, x, y);
        }
    }
    return out;
}

/**
 * Покачивание: nearest-поворот на angle рад вокруг точки опоры
 * (по умолчанию низ-центр — «укоренённое» качание, как sway).
 * pivot: { x, y } в px.
 */
export function swingFrame(src, w, h, angle, pivot) {
    const px = pivot ? pivot.x : (w - 1) / 2;
    const py = pivot ? pivot.y : h - 1;
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    const out = new Uint8Array(w * h * 4);
    for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
            // Обратный поворот dest → src (экранные координаты, y вниз).
            const dx = x - px;
            const dy = y - py;
            blit(src, out, w, h, Math.round(px + dx * cos + dy * sin), Math.round(py - dx * sin + dy * cos), x, y);
        }
    }
    return out;
}

/**
 * Цикл из опорного кадра: ops — список дескрипторов, один выходной кадр на
 * дескриптор; операции внутри дескриптора применяются по порядку:
 * { dx?, dy?, bob?, squash?, swing?, mirror? }
 * (dx/dy — сдвиг; bob — парение вверх; squash — приседание;
 * swing — угол покачивания в рад; mirror — зеркалирование).
 */
export function deriveCycle(src, w, h, ops) {
    return ops.map((op) => applyOps(src, w, h, op));
}

function applyOps(src, w, h, op) {
    let frame = src;
    if (op.dx || op.dy) frame = shiftFrame(frame, w, h, op.dx ?? 0, op.dy ?? 0);
    if (op.bob) frame = bobFrame(frame, w, h, op.bob);
    if (op.squash) frame = squashFrame(frame, w, h);
    if (op.swing) frame = swingFrame(frame, w, h, op.swing, op.pivot);
    if (op.mirror) frame = mirrorFrame(frame, w, h);
    return frame;
}