LOOK 直播活動地圖生成器方案

雲音樂大前端團隊發表於2021-12-14
本文作者:李一笑

對於前端而言,與視覺稿打交道是必不可少的,因為我們需要對照著視覺稿來確定元素的位置、大小等資訊。如果是比較簡單的頁面,手動調整每個元素所帶來的工作量尚且可以接受;然而當視覺稿中素材數量較大時,手動調整每個元素便不再是個可以接受的策略了。

在最近的活動開發中,筆者就剛好碰到了這個問題。這次活動開發需要完成一款大富翁遊戲,而作為一款大富翁遊戲,地圖自然是必不可少的。在整個地圖中,有很多的不同種類的方格,如果一個個手動去調整位置,工作量是很大的。那麼有沒有一種方案能夠幫助我們快速確定方格的位置和種類呢?下面便是筆者所採用的方法。

方案簡述

位點圖

首先,我們需要視覺同學提供一張特殊的圖片,稱之為位點圖。

這張圖片要滿足以下幾個要求:

  1. 在每個方格左上角的位置,放置一個 1px 的畫素點,不同型別的方格用不同顏色表示。
  2. 底色為純色:便於區分背景和方格。
  3. 大小和地圖背景圖大小一致:便於從圖中讀出的座標可以直接使用。

bitmap

上圖為一個示例,在每個路徑方格左上角的位置都有一個 1px 的畫素點。為了看起來明顯一點,這裡用紅色的圓點來表示。在實際情況中,不同的點由於方格種類不同,顏色也是不同的。

bitmap2

上圖中用黑色邊框標出了素材圖的輪廓。可以看到,紅色圓點和每個路徑方格是一一對應的關係。

讀取位點圖

在上面的位點圖中,所有方格的位置和種類資訊都被標註了出來。我們接下來要做的,便是將這些資訊讀取出來,並生成一份 json 檔案來供我們後續使用。

const JImp = require('jimp');
const nodepath = require('path');

function parseImg(filename) {
    JImp.read(filename, (err, image) => {
        const { width, height } = image.bitmap;

        const result = [];

        // 圖片左上角畫素點的顏色, 也就是背景圖的顏色
        const mask = image.getPixelColor(0, 0);

        // 篩選出非 mask 位置點
        for (let y = 0; y < height; ++y) {
            for (let x = 0; x < width; ++x) {
                const color = image.getPixelColor(x, y);
                if (mask !== color) {
                    result.push({
                        // x y 座標
                        x,
                        y,
                        // 方格種類
                        type: color.toString(16).slice(0, -2),
                    });
                }
            }
        }

        // 輸出
        console.log(JSON.stringify({
            // 路徑
            path: result,
        }));
    });
}

parseImg('bitmap.png');

在這裡我們使用了 jimp 用於影像處理,通過它我們能夠去掃描這張圖片中每個畫素點的顏色和位置。

至此我們得到了包含所有方格位置和種類資訊的 json 檔案:

{
    "path": [
        {
            "type": "",
            "x": 0,
            "y": 0,
        },
        // ...
    ],
}

其中,x y 為方格左上角的座標;type 為方格種類,值為顏色值,代表不同種類的地圖方格。

通路連通演算法

對於我們的專案而言,只確定路徑點是不夠的,還需要將這些點連線成一個完整的通路。為此,我們需要找到一條由這些點構成的最短連線路徑。

程式碼如下:

function takePath(point, points) {
    const candidate = (() => {
        // 按照距離從小到大排序
        const pp = [...points].filter((i) => i !== point);
        const [one, two] = pp.sort((a, b) => measureLen(point, a) - measureLen(point, b));

        if (!one) {
            return [];
        }

        // 如果兩個距離 比較小,則窮舉兩個路線,選擇最短連通圖路徑。
        if (two && measureLen(one, two) < 20000) {
            return [one, two];
        }
        return [one];
    })();

    let min = Infinity;
    let minPath = [];
    for (let i = 0; i < candidate.length; ++i) {
        // 遞迴找出最小路徑
        const subpath = takePath(candidate[i], removeItem(points, candidate[i]));

        const path = [].concat(point, subpath);
        // 測量路徑總長度
        const distance = measurePathDistance(path);

        if (distance < min) {
            min = distance;
            minPath = subpath;
        }
    }

    return [].concat(point, minPath);
}

到這裡,我們已經完成了所有的準備工作,可以開始繪製地圖了。在繪製地圖時,我們只需要先讀取 json 檔案,再根據 json 檔案內的座標資訊和種類資訊來放置對應素材即可。

方案優化

上述方案能夠解決我們的問題,但仍有一些不太方便的地方:

  1. 只有 1px 的畫素點太小了,肉眼無法辨別。不管是視覺同學還是開發同學,如果點錯了位置就很難排查。
  2. 位點圖中包含的資訊還是太少了,顏色僅僅對應種類,我們希望能夠包含更多的資訊,比如點之間的排列順序、方格的大小等。

畫素點合併

對於第一個問題,我們可以讓視覺同學在畫圖的時候,將 1px 的畫素點擴大成一個肉眼足夠辨識的區域。需要注意兩個區域之間不要有重疊。

bitmap3

這時候就要求我們對程式碼做一些調整。在之前的程式碼中,當我們掃描到某個顏色與背景色不同的點時,會直接記錄其座標和顏色資訊;現在當我們掃描到某個顏色與背景色不同的點時,還需要進行一次區域合併,將所有相鄰且相同顏色的點都納入進來。

區域合併的思路借鑑了下影像處理的區域生長演算法。區域生長演算法的思路是以一個畫素點為起點,將該點周圍符合條件的點納入進來,之後再以新納入的點為起點,向新起點相鄰的點擴張,直到所有符合條件條件的點都被納入進來。這樣就完成了一次區域合併。不斷重複該過程,直到整個影像中所有的點都被掃描完畢。

我們的思路和區域生長演算法非常類似:

  1. 依次掃描影像中的畫素點,當掃描到顏色與背景色不同的點時,記錄下該點的座標和顏色。

    步驟1.png

  2. 之後掃描與該點相鄰的 8 個點,將這些點打上”已掃描“的標記。篩選出其中顏色與背景色不同且尚未被掃描過的點,放入待掃描的佇列中。

    步驟2.png

  3. 從待掃描佇列中取出下一個需要掃描的點,重複步驟 1 和步驟 2。
  4. 直到待掃描的佇列為空時,我們就掃描完了一整個有顏色的區域。區域合併完畢。

    步驟3.png

const JImp = require('jimp');

let image = null;
let maskColor = null;

// 判斷兩個顏色是否為相同顏色 -> 為了處理影像顏色有誤差的情況, 不採用相等來判斷
const isDifferentColor = (color1, color2) => Math.abs(color1 - color2) > 0xf000ff;

// 判斷是(x,y)是否超出邊界
const isWithinImage = ({ x, y }) => x >= 0 && x < image.width && y >= 0 && y < image.height;

// 選擇數量最多的顏色
const selectMostColor = (dotColors) => { /* ... */ };

// 選取左上角的座標
const selectTopLeftDot = (reginDots) => { /* ... */ };

// 區域合併
const reginMerge = ({ x, y }) => {
    const color = image.getPixelColor(x, y);
    // 掃描過的點
    const reginDots = [{ x, y, color }];
    // 所有掃描過的點的顏色 -> 掃描完成後, 選擇最多的色值作為這一區域的顏色
    const dotColors = {};
    dotColors[color] = 1;

    for (let i = 0; i < reginDots.length; i++) {
        const { x, y, color } = reginDots[i];

        // 朝臨近的八個個方向生長
        const seeds = (() => {
            const candinates = [/* 左、右、上、下、左上、左下、右上、右下 */];

            return candinates
                // 去除超出邊界的點
                .filter(isWithinImage)
                // 獲取每個點的顏色
                .map(({ x, y }) => ({ x, y, color: image.getPixelColor(x, y) }))
                // 去除和背景色顏色相近的點
                .filter((item) => isDifferentColor(item.color, maskColor));
        })();

        for (const seed of seeds) {
            const { x: seedX, y: seedY, color: seedColor } = seed;

            // 將這些點新增到 reginDots, 作為下次掃描的邊界
            reginDots.push(seed);

            // 將該點設定為背景色, 避免重複掃描
            image.setPixelColor(maskColor, seedX, seedY);

            // 該點顏色為沒有掃描到的新顏色, 將顏色增加到 dotColors 中
            if (dotColors[seedColor]) {
                dotColors[seedColor] += 1;
            } else {
                // 顏色為舊顏色, 增加顏色的 count 值
                dotColors[seedColor] = 1;
            }
        }
    }

    // 掃描完成後, 選擇數量最多的色值作為區域的顏色
    const targetColor = selectMostColor(dotColors);

    // 選擇最左上角的座標作為當前區域的座標
    const topLeftDot = selectTopLeftDot(reginDots);

    return {
        ...topLeftDot,
        color: targetColor,
    };
};

const parseBitmap = (filename) => {
    JImp.read(filename, (err, img) => {
        const result = [];
        const { width, height } = image.bitmap;
        // 背景顏色
        maskColor = image.getPixelColor(0, 0);
        image = img;

        for (let y = 0; y < height; ++y) {
            for (let x = 0; x < width; ++x) {
                const color = image.getPixelColor(x, y);

                // 顏色不相近
                if (isDifferentColor(color, maskColor)) {
                    // 開啟種子生長程式, 依次掃描所有臨近的色塊
                    result.push(reginMerge({ x, y }));
                }
            }
        }
    });
};

顏色包含額外資訊

在之前的方案中,我們都是使用顏色值來表示種類,但實際上顏色值所能包含的資訊還有很多。

一個顏色值可以用 rgba 來表示,因此我們可以讓 r、g、b、a 分別代表不同的資訊,如 r 代表種類、g 代表寬度、b 代表高度、a 代表順序。雖然 rgba 每個的數量都有限(r、g、b 的範圍為 0-255,a 的範圍為 0-99),但基本足夠我們使用了。

rgba.png

當然,你甚至可以再進一步,讓每個數字都表示一種資訊,不過這樣每種資訊的範圍就比較小,只有 0-9。

總結

對於素材量較少的場景,前端可以直接從視覺稿中確認素材資訊;當素材量很多時,直接從視覺稿中確認素材資訊的工作量就變得非常大,因此我們使用了位點圖來輔助我們獲取素材資訊。

無標題-2021-09-28-1450.png

地圖就是這樣一種典型的場景,在上面的例子中,我們已經通過從位點圖中讀出的資訊成功繪製了地圖。我們的步驟如下:

  1. 視覺同學提供位點圖,作為承載資訊的載體,它需要滿足以下三個要求:

    1. 大小和地圖背景圖大小一致:便於我們從圖中讀出的座標可以直接使用。
    2. 底色為純色:便於區分背景和方格。
    3. 在每個方格左上角的位置,放置一個方格,不同顏色的方格表示不同型別。
  2. 通過 jimp 掃描圖片上每個畫素點的顏色,從而生成一份包含各個方格位置和種類的 json。
  3. 繪製地圖時,先讀取 json 檔案,再根據 json 檔案內的座標資訊和種類資訊來放置素材。

gif.gif

上述方案並非完美無缺的,在這裡我們主要對於位點圖進行了改進,改進方案分為兩方面:

  1. 由於 1px 的畫素點對肉眼來說過小,視覺同學畫圖以及我們除錯的時候,都十分不方便。因此我們將畫素點擴大為一個區域,在掃描時,對相鄰的相同顏色的畫素點進行合併。
  2. 讓顏色的 rgba 分別對應一種資訊,擴充位點圖中的顏色值能夠給我們提供的資訊。

我們在這裡只著重講解了獲取地圖資訊的部分,至於如何繪製地圖則不在本篇的敘述範圍之內。在我的專案中使用了 pixi.js 作為引擎來渲染,完整專案可以參考這裡,在此不做贅述。

FAQ

  • 在位點圖上,直接使用顏色塊的大小作為路徑方格的寬高可以不?

    當然可以。但這種情況是有侷限性的,當我們的素材很多且彼此重疊的時候,如果依然用方塊大小作為寬高,那麼在位點圖上的方塊就會彼此重疊,影響我們讀取位置資訊。

  • 如何處理有損圖的情況?

    有損圖中,圖形邊緣處的顏色和中心的顏色會略微有所差異。因此需要增加一個判斷函式,只有掃描到的點的顏色與背景色的差值大於某個數字後,才認為是不同顏色的點,並開始區域合併。同時要注意在位點圖中方塊的顏色儘量選取與背景色色值相差較大的顏色。

    這個判斷函式,就是我們上面程式碼中的 isDifferentColor 函式。

    const isDifferentColor = (color1, color2) => Math.abs(color1 - color2) > 0xf000ff;
  • 判斷兩個顏色不相等的 0xf000ff 是怎麼來的?

    隨便定的。這個和圖片裡包含顏色有關係,如果你的背景色和圖片上點的顏色非常相近的話,這個值就需要小一點;如果背景色和圖上點的顏色相差比較大,這個值就可以大一點。

參考資料

相關文章