從色塊到動畫:獨立遊戲的美術迭代踩坑記
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 遊戲,在打怪的過程中學日文動詞變化。這篇是「費曼的實習生」系列,記錄獨立遊戲開發過程中的具體經驗。