從色塊到動畫:獨立遊戲的美術迭代踩坑記

ConjuGate 第一版的畫面:綠色方塊是玩家,紅色方塊是敵人,藍色方塊是無人機。全部都是 SKSpriteNode(color:size:) 生出來的純色矩形。

現在的畫面:史萊姆有 Run/Hurt/Death 三組動畫,玩家有四方向行走動畫,Boss 是一隻完整的 slime knight。從色塊到這裡,中間經過了大量的素材處理踩坑。這篇記錄我怎麼一步步把色塊換成動畫,以及過程中遇到的具體問題。

色塊階段:為什麼一開始不用美術

第一版用色塊有一個好處:我可以專注在 gameplay 上。移動手感對不對、碰撞判定準不準、出題節奏快不快,這些都跟美術無關。用色塊測試,改一行 code 就能跑,不用等素材。

色塊階段持續了大約整個大改有 20 多次 。在這段時間裡,我加了射擊系統、砲塔、10 關制、FSRS 出題排程。如果一開始就找素材,這些功能的開發速度會慢很多,因為每次改尺寸或碰撞範圍都要重新調整素材。

第一次換皮:史萊姆替換綠色方塊

找到一張 sprite sheet(Slime1_Walk_with_shadow.png),上面是一隻史萊姆的行走動畫,8 幀,每幀 64×64。

我用 macOS 內建的 sips 指令把 sprite sheet 裁成 8 張獨立的 PNG,丟進 Xcode 的 sprite atlas。第一次跑起來,第 8 幀的畫面不對,裁到了下一排的像素。

問題出在 sips 的裁切精度。sprite sheet 的每一幀之間有 sub-pixel 的間距,sips 的 crop 座標不夠精確,累積到第 8 幀就偏了。

我改用 Python PIL 重新裁切,指定精確的 pixel 座標,順便把每幀從 64px 放大到 128px(2x nearest neighbor),讓 pixel art 在 Retina 螢幕上更清晰。

# 精確裁切 + 2x 放大
for i in range(8):
    x = i * 64
    frame = img.crop((x, 0, x + 64, 64))
    frame = frame.resize((128, 128), Image.NEAREST)
    frame.save(f"slime_walk_{i}.png")

裁完之後 displaySize 也要調。128px 的素材在畫面上顯示多大,由 SpriteKit 的 size 屬性決定。原本的綠色方塊是 36pt,換成史萊姆之後改成 48pt,跑了一次覺得太小,改成 56pt,再改成 112pt(因為後來加了 Hurt 和 Death 動畫,素材本身比較大)。

這個 displaySize 調了四次才定下來。

玩家角色:green screen 去背

玩家的素材是一張 sprite sheet,背景是 #00ff00 的純綠色(green screen)。SpriteKit 沒有內建的去背功能,我寫了一段 Python 用 PIL 處理:

  • 掃描每個 pixel,RGB 接近 (0, 255, 0) 的設成透明(用 tolerance 容許色差)
  • 裁掉透明的 padding,縮到剛好包住角色的範圍
  • 統一 resize 到 96×96

四個方向(上下左右)各 6 幀,總共 24 張 PNG,496KB。

第一次去背的 tolerance 設太小,角色邊緣留了一圈綠色的鋸齒。把 tolerance 從 30 拉到 80 之後,邊緣乾淨了,但角色身上某些偏綠的裝飾也被吃掉。最後用 50 取得平衡。

Boss 素材:JPG 的 green screen 更難處理

Boss(slime knight)的素材是 JPG 格式。JPG 有壓縮失真,green screen 的顏色不是精確的 #00ff00,而是附近的一堆相近色。idle 動畫 8 幀、hurt 5 幀、death 10 幀,每一幀的綠色都有微妙的色差。

用處理 PNG 的同一組 tolerance 去背,Boss 周圍留了一圈噪點。我把去背改成 aggressive 模式:先把接近綠色的 pixel 全部設透明,再對邊緣做一次 erosion(往內縮 1px),把殘留的綠色鋸齒吃掉。23 張圖片重新處理了一遍。

尺寸一致性:加新敵人時的坑

原版史萊姆(Slime1)的素材是 128px,displaySize 112pt。後來加了 Slime2(藍色)和 Slime3(火焰),素材也是 128px,用同樣的 displaySize。

跑起來 Slime2 和 Slime3 比 Slime1 大了一圈。原因:三組素材的角色在畫框裡的佔比不同。Slime1 的角色填滿了大約 70% 的畫框,Slime2 填了 90%。同樣的 displaySize,Slime2 看起來就是比較大。

我先把 Slime2/3 的素材從 128px 縮到 96px,跑了一次還是太大。再把 displaySize 從 112pt 調到 56pt,太小了。最後落在 48pt。從發現問題到調好,花了三個 commit。

後來我把所有敵人改成用 tint color 的方式生成變體,用一組素材搭配不同顏色:

static func slimeTinted(_ color: SKColor) -> SkinConfig {
    var config = slimeBase
    config.tintColor = color
    return config
}

static let slimeBlue   = slimeTinted(SKColor(red: 0.4, green: 0.5, blue: 1.0, alpha: 1))
static let slimeRed    = slimeTinted(SKColor(red: 1.0, green: 0.4, blue: 0.4, alpha: 1))
static let slimePurple = slimeTinted(SKColor(red: 0.7, green: 0.3, blue: 0.9, alpha: 1))
static let slimeGold   = slimeTinted(SKColor(red: 1.0, green: 0.8, blue: 0.2, alpha: 1))

這樣就不用管每組素材的尺寸一致性了,因為只有一組素材。

Texture Cache 和 filteringMode

每次生成一隻史萊姆都要載入 atlas、解壓 texture,在敵人密集出現的關卡會造成卡頓。EnemySkin 加了一層 static cache:

private static var cache: [String: EnemySkin] = [:]

static func load(name: String, config: SkinConfig) -> EnemySkin {
    if let cached = cache[name] { return cached }
    // ... 載入 atlas, 建立 skin
    cache[name] = skin
    return skin
}

同一種敵人只載入一次,之後都從 cache 拿。

另一個容易忽略的設定是 filteringMode。SpriteKit 預設用 linear filtering,會把 pixel art 的邊緣模糊化。設成 .nearest 之後,像素邊緣是銳利的,pixel art 才會看起來對。每一張 texture 載入之後都要設一次。

時間軸

整個美術迭代的過程:

  • 前 20 個 大改:色塊階段,專注 gameplay
  • UI重構:第一次換皮,用 sips 裁切,裁歪了
  • 重新校準:改用 Python PIL 重裁 + 2x 放大
  • 接下來 5 個大改:加 Hurt/Death 動畫,調 displaySize
  • 做圖的時候沒注意到:玩家 green screen 去背
  • 再後面:加 Slime2/3,花 3 個 commit 調尺寸一致性
  • 最後改用 tint color 統一管理變體,不再需要多組素材
  • Boss 素材:JPG green screen 的 aggressive 去背

從色塊到完整動畫,大約佔了總工程的 40%。素材處理(裁切、去背、尺寸調整)花的時間比我預期的多很多,而且大部分問題要跑起來才看得到。


ConjuGate 是一個用 SpriteKit 寫的 iOS 遊戲,在打怪的過程中學日文動詞變化。這篇是「費曼的實習生」系列,記錄獨立遊戲開發過程中的具體經驗。