Improve handpainted battle unit readability
BIN
docs/battle-ui-icons-combat-cut-in-v1.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/battle-ui-icons-combat-preview-v2.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/battle-ui-icons-unit-detail-v1.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/battle-ui-icons-unit-detail-v2.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
36
docs/handpaint-batch1-alpha-report.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Handpaint Batch 1 Alpha Validation
|
||||
|
||||
## unit-guan-yu
|
||||
- base: size (5008, 1252), partial alpha 0, opaque pixels 2269236
|
||||
- action: size (11268, 1252), partial alpha 0, opaque pixels 4929316
|
||||
- contact: `handpaint-batch1-unit-guan-yu-contact.png`
|
||||
- animation: `handpaint-batch1-unit-guan-yu-animation.gif`
|
||||
- before/after: `handpaint-batch1-unit-guan-yu-before-after.png`
|
||||
|
||||
## unit-zhang-fei
|
||||
- base: size (5008, 1252), partial alpha 0, opaque pixels 2215087
|
||||
- action: size (11268, 1252), partial alpha 0, opaque pixels 4580452
|
||||
- contact: `handpaint-batch1-unit-zhang-fei-contact.png`
|
||||
- animation: `handpaint-batch1-unit-zhang-fei-animation.gif`
|
||||
- before/after: `handpaint-batch1-unit-zhang-fei-before-after.png`
|
||||
|
||||
## unit-rebel
|
||||
- base: size (5008, 1252), partial alpha 0, opaque pixels 2168171
|
||||
- action: size (11268, 1252), partial alpha 0, opaque pixels 5156024
|
||||
- contact: `handpaint-batch1-unit-rebel-contact.png`
|
||||
- animation: `handpaint-batch1-unit-rebel-animation.gif`
|
||||
- before/after: `handpaint-batch1-unit-rebel-before-after.png`
|
||||
|
||||
## unit-rebel-archer
|
||||
- base: size (5008, 1252), partial alpha 0, opaque pixels 1902601
|
||||
- action: size (11268, 1252), partial alpha 0, opaque pixels 4808052
|
||||
- contact: `handpaint-batch1-unit-rebel-archer-contact.png`
|
||||
- animation: `handpaint-batch1-unit-rebel-archer-animation.gif`
|
||||
- before/after: `handpaint-batch1-unit-rebel-archer-before-after.png`
|
||||
|
||||
## unit-rebel-cavalry
|
||||
- base: size (5008, 1252), partial alpha 0, opaque pixels 1820794
|
||||
- action: size (11268, 1252), partial alpha 0, opaque pixels 4278832
|
||||
- contact: `handpaint-batch1-unit-rebel-cavalry-contact.png`
|
||||
- animation: `handpaint-batch1-unit-rebel-cavalry-animation.gif`
|
||||
- before/after: `handpaint-batch1-unit-rebel-cavalry-before-after.png`
|
||||
BIN
docs/handpaint-batch1-battle-guan-yu.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/handpaint-batch1-battle-overview.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/handpaint-batch1-battle-rebel-archer.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/handpaint-batch1-battle-rebel-cavalry.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
docs/handpaint-batch1-battle-rebel.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-battle-zhang-fei.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/handpaint-batch1-idle-breathing-01.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
docs/handpaint-batch1-idle-breathing-02.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-03.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-04.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-05.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-browser.gif
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
docs/handpaint-batch1-idle-breathing-strip.png
Normal file
|
After Width: | Height: | Size: 409 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-01.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-02.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-03.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-04.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-05.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-browser.gif
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
docs/handpaint-batch1-idle-breathing-visible-strip.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
docs/handpaint-batch1-idle-stability-01.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/handpaint-batch1-idle-stability-02.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/handpaint-batch1-idle-stability-03.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/handpaint-batch1-idle-stability-browser.gif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/handpaint-batch1-idle-stability-strip.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/handpaint-batch1-liu-guan-size-match-battle.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
docs/handpaint-batch1-overview-contact.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/handpaint-batch1-public-deploy-battle.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-public-deploy-title.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
docs/handpaint-batch1-unit-guan-yu-animation.gif
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
docs/handpaint-batch1-unit-guan-yu-before-after.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/handpaint-batch1-unit-guan-yu-contact.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/handpaint-batch1-unit-rebel-animation.gif
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
docs/handpaint-batch1-unit-rebel-archer-animation.gif
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/handpaint-batch1-unit-rebel-archer-before-after.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/handpaint-batch1-unit-rebel-archer-contact.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
docs/handpaint-batch1-unit-rebel-before-after.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/handpaint-batch1-unit-rebel-cavalry-animation.gif
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
docs/handpaint-batch1-unit-rebel-cavalry-before-after.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/handpaint-batch1-unit-rebel-cavalry-contact.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/handpaint-batch1-unit-rebel-contact.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
docs/handpaint-batch1-unit-zhang-fei-animation.gif
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
docs/handpaint-batch1-unit-zhang-fei-before-after.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/handpaint-batch1-unit-zhang-fei-contact.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-01.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-02.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-03.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-04.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-05.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-06.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-07.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-after.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-01.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-02.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-03.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-04.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-05.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-06.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-07.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-08.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-large-after.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-220-selected.png
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-moved.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-moving.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-fit-selected.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-01.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-02.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-03.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-04.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-05.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-06.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-07.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-after.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs/handpaint-batch1-zhang-fei-walk-long-selected.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/liu-bei-handpaint-sample-animation.gif
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
docs/liu-bei-handpaint-sample-battle.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/liu-bei-handpaint-sample-before-after.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/liu-bei-handpaint-sample-contact.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
docs/liu-bei-handpaint-sample-detail.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
53
docs/tactical-unit-sprite-art-direction.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Tactical Unit Sprite Art Direction
|
||||
|
||||
## Goal
|
||||
|
||||
Create new frame-by-frame raster unit sprites for the battle map. The sprites must read clearly at the live desktop battle scale, where a unit is displayed around a 50px tile, while still looking intentional when inspected at the original 313x313 frame size.
|
||||
|
||||
This pass is not a filter pass, alpha fix, vector redraw, or procedural shape generator. It is an art-directed raster repaint pass. Each pose should feel drawn as a distinct frame, with changes in stance, cloth, weapon position, and motion energy.
|
||||
|
||||
## Copyright Boundary
|
||||
|
||||
The visual mood may reference historical Three Kingdoms tactical RPGs: heroic silhouettes, ornate but readable armor, strong faction colors, and dramatic weapon poses.
|
||||
|
||||
Do not copy KOEI artwork, character portraits, logos, sprite silhouettes, UI assets, or exact costume designs. Use original silhouettes, original clothing details, and original frame layouts made for this project.
|
||||
|
||||
## Battle Readability Rules
|
||||
|
||||
- The body pixels of the unit must be fully opaque.
|
||||
- The surrounding frame background may be transparent after extraction.
|
||||
- At 50px display size, the silhouette must be readable before interior detail.
|
||||
- Avoid hairline strokes, pale low-contrast outlines, watercolor transparency, glow-only shapes, or thin ornamental clutter.
|
||||
- Use a dark outer contour, large color planes, and one or two strong highlight areas.
|
||||
- Weapon identity must be visible from the silhouette.
|
||||
- Friendly heroes should be richer and more ornate than common soldiers, but not so dense that the 50px battle read is lost.
|
||||
|
||||
## Liu Bei Sample Direction
|
||||
|
||||
Liu Bei is the approval-gate sample.
|
||||
|
||||
Core read:
|
||||
- Noble commander with a sword.
|
||||
- Deep green outer robe, warm cream inner robe, muted bronze/gold armor trim.
|
||||
- Upright, restrained, humane leader posture rather than brute force.
|
||||
- Sword should be readable in attack frames and visible enough in idle/walk frames.
|
||||
|
||||
Frame requirements:
|
||||
- Base sheet: 4 directions x 16 frames.
|
||||
- Per direction: idle 8 frames, walk 8 frames.
|
||||
- Action sheet: 4 directions x 36 frames.
|
||||
- Per direction: attack 10, strategy 8, item 8, hurt 4, celebrate 6.
|
||||
- Frame size remains 313x313.
|
||||
- Frames should preserve the same character but vary pose, robe hem, sleeves, sword angle, and weight shift.
|
||||
|
||||
## Approval Gate
|
||||
|
||||
Only Liu Bei may be produced in this sample phase. Do not expand to Guan Yu, Zhang Fei, rebel soldiers, or the full roster until the sample has been reviewed and approved.
|
||||
|
||||
Required sample outputs:
|
||||
- 313x313 contact sheet for Liu Bei.
|
||||
- Animated GIF or browser capture for idle/walk/action.
|
||||
- 100% desktop browser screenshot from the battle map.
|
||||
- Before/after comparison against the current checked-in Liu Bei sprite.
|
||||
|
||||
No deployment, commit, or push is allowed before sample approval.
|
||||
906
scripts/assemble-handpaint-batch1-sprites.py
Normal file
@@ -0,0 +1,906 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WORK_DIR = ROOT / "tmp" / "batch1-handpaint-sprites"
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
|
||||
FRAME = 313
|
||||
DIRECTIONS = ("south", "east", "north", "west")
|
||||
BASE_FRAMES_PER_DIRECTION = 16
|
||||
ACTION_COUNTS = {
|
||||
"attack": 10,
|
||||
"strategy": 8,
|
||||
"item": 8,
|
||||
"hurt": 4,
|
||||
"celebrate": 6,
|
||||
}
|
||||
ACTION_OFFSETS = {
|
||||
"attack": 0,
|
||||
"strategy": 10,
|
||||
"item": 18,
|
||||
"hurt": 26,
|
||||
"celebrate": 30,
|
||||
}
|
||||
ACTION_FRAMES_PER_DIRECTION = sum(ACTION_COUNTS.values())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitSpec:
|
||||
key: str
|
||||
label: str
|
||||
max_width: int = 292
|
||||
max_height: int = 300
|
||||
bottom: int = 306
|
||||
stretch_x: float = 1.12
|
||||
|
||||
|
||||
UNIT_SPECS = [
|
||||
UnitSpec("unit-guan-yu", "Guan Yu", max_width=306, max_height=304, stretch_x=1.18),
|
||||
UnitSpec("unit-zhang-fei", "Zhang Fei", max_width=306, max_height=302, stretch_x=1.15),
|
||||
UnitSpec("unit-rebel", "Yellow Turban Infantry", max_width=286, max_height=298, stretch_x=1.10),
|
||||
UnitSpec("unit-rebel-archer", "Yellow Turban Archer", max_width=306, max_height=298, stretch_x=1.08),
|
||||
UnitSpec("unit-rebel-cavalry", "Yellow Turban Cavalry", max_width=306, max_height=292, stretch_x=1.02),
|
||||
]
|
||||
|
||||
MANUAL_ACTION_CROPS: dict[str, dict[str, list[tuple[int, int, int, int]]]] = {
|
||||
"unit-guan-yu": {
|
||||
"attack": [
|
||||
(78, 154, 210, 286),
|
||||
(240, 154, 248, 286),
|
||||
(406, 154, 260, 286),
|
||||
(566, 154, 252, 286),
|
||||
(724, 154, 238, 286),
|
||||
(886, 154, 252, 286),
|
||||
(1046, 154, 252, 286),
|
||||
(1220, 154, 310, 286),
|
||||
(1408, 154, 246, 286),
|
||||
(1584, 154, 218, 286),
|
||||
],
|
||||
"middle": [
|
||||
(250, 500, 330, 340),
|
||||
(555, 500, 310, 340),
|
||||
(825, 500, 310, 340),
|
||||
(1110, 500, 330, 340),
|
||||
(1420, 500, 330, 340),
|
||||
],
|
||||
"lower": [
|
||||
(530, 762, 360, 330),
|
||||
(835, 762, 340, 330),
|
||||
(1160, 762, 380, 330),
|
||||
],
|
||||
},
|
||||
"unit-zhang-fei": {
|
||||
"attack": [
|
||||
(82, 154, 222, 286),
|
||||
(250, 154, 238, 286),
|
||||
(420, 154, 262, 286),
|
||||
(590, 154, 262, 286),
|
||||
(756, 154, 266, 286),
|
||||
(925, 154, 270, 286),
|
||||
(1096, 154, 260, 286),
|
||||
(1268, 154, 270, 286),
|
||||
(1432, 154, 240, 286),
|
||||
(1586, 154, 218, 286),
|
||||
],
|
||||
"middle": [
|
||||
(260, 502, 360, 340),
|
||||
(555, 502, 330, 340),
|
||||
(825, 502, 330, 340),
|
||||
(1090, 502, 350, 340),
|
||||
(1402, 502, 360, 340),
|
||||
],
|
||||
"lower": [
|
||||
(520, 764, 380, 330),
|
||||
(835, 764, 360, 330),
|
||||
(1160, 764, 390, 330),
|
||||
],
|
||||
},
|
||||
"unit-rebel": {
|
||||
"attack": [
|
||||
(82, 154, 214, 286),
|
||||
(260, 154, 218, 286),
|
||||
(454, 154, 236, 286),
|
||||
(635, 154, 238, 286),
|
||||
(810, 154, 236, 286),
|
||||
(990, 154, 250, 286),
|
||||
(1195, 154, 236, 286),
|
||||
(1375, 154, 230, 286),
|
||||
(1518, 154, 224, 286),
|
||||
(1618, 154, 210, 286),
|
||||
],
|
||||
"middle": [
|
||||
(315, 510, 350, 340),
|
||||
(580, 510, 340, 340),
|
||||
(825, 510, 320, 340),
|
||||
(1082, 510, 330, 340),
|
||||
(1320, 510, 330, 340),
|
||||
],
|
||||
"lower": [
|
||||
(650, 765, 360, 330),
|
||||
(852, 765, 330, 330),
|
||||
(1088, 765, 350, 330),
|
||||
],
|
||||
},
|
||||
"unit-rebel-archer": {
|
||||
"attack": [
|
||||
(70, 154, 210, 286),
|
||||
(235, 154, 214, 286),
|
||||
(405, 154, 230, 286),
|
||||
(578, 154, 242, 286),
|
||||
(748, 154, 250, 286),
|
||||
(912, 154, 250, 286),
|
||||
(1080, 154, 260, 286),
|
||||
(1250, 154, 260, 286),
|
||||
(1420, 154, 232, 286),
|
||||
(1586, 154, 216, 286),
|
||||
],
|
||||
"middle": [
|
||||
(275, 510, 330, 340),
|
||||
(575, 510, 330, 340),
|
||||
(835, 510, 330, 340),
|
||||
(1095, 510, 330, 340),
|
||||
(1370, 510, 350, 340),
|
||||
],
|
||||
"lower": [
|
||||
(565, 765, 370, 330),
|
||||
(835, 765, 340, 330),
|
||||
(1085, 765, 370, 330),
|
||||
],
|
||||
},
|
||||
"unit-rebel-cavalry": {
|
||||
"attack": [
|
||||
(82, 154, 280, 286),
|
||||
(255, 154, 286, 286),
|
||||
(430, 154, 300, 286),
|
||||
(605, 154, 300, 286),
|
||||
(780, 154, 300, 286),
|
||||
(960, 154, 320, 286),
|
||||
(1155, 154, 330, 286),
|
||||
(1355, 154, 310, 286),
|
||||
(1510, 154, 282, 286),
|
||||
(1610, 154, 230, 286),
|
||||
],
|
||||
"middle": [
|
||||
(350, 510, 410, 340),
|
||||
(610, 510, 390, 340),
|
||||
(845, 510, 390, 340),
|
||||
(1095, 510, 390, 340),
|
||||
(1335, 510, 420, 340),
|
||||
],
|
||||
"lower": [
|
||||
(560, 765, 440, 330),
|
||||
(855, 765, 390, 330),
|
||||
(1115, 765, 420, 330),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
ACTION_CROPS_V2: dict[str, dict[str, list[tuple[int, int, int, int]]]] = {
|
||||
"unit-guan-yu": {
|
||||
"attack": [
|
||||
(92, 150, 220, 280),
|
||||
(280, 150, 245, 280),
|
||||
(500, 150, 255, 280),
|
||||
(700, 150, 255, 280),
|
||||
(875, 150, 245, 280),
|
||||
(1045, 150, 255, 280),
|
||||
(1250, 150, 315, 280),
|
||||
(1430, 150, 245, 280),
|
||||
(1590, 150, 220, 280),
|
||||
(1698, 150, 180, 280),
|
||||
],
|
||||
"middle": [
|
||||
(255, 475, 330, 330),
|
||||
(555, 475, 310, 330),
|
||||
(825, 475, 310, 330),
|
||||
(1130, 475, 350, 330),
|
||||
(1465, 475, 330, 330),
|
||||
],
|
||||
"lower": [
|
||||
(540, 735, 380, 320),
|
||||
(835, 735, 360, 320),
|
||||
(1165, 735, 400, 320),
|
||||
],
|
||||
},
|
||||
"unit-zhang-fei": {
|
||||
"attack": [
|
||||
(88, 150, 230, 280),
|
||||
(272, 150, 250, 280),
|
||||
(486, 150, 265, 280),
|
||||
(690, 150, 275, 280),
|
||||
(880, 150, 260, 280),
|
||||
(1062, 150, 255, 280),
|
||||
(1230, 150, 245, 280),
|
||||
(1398, 150, 245, 280),
|
||||
(1570, 150, 245, 280),
|
||||
(1695, 150, 185, 280),
|
||||
],
|
||||
"middle": [
|
||||
(250, 475, 350, 330),
|
||||
(565, 475, 330, 330),
|
||||
(850, 475, 330, 330),
|
||||
(1135, 475, 360, 330),
|
||||
(1460, 475, 360, 330),
|
||||
],
|
||||
"lower": [
|
||||
(525, 735, 400, 320),
|
||||
(850, 735, 370, 320),
|
||||
(1190, 735, 410, 320),
|
||||
],
|
||||
},
|
||||
"unit-rebel": {
|
||||
"attack": [
|
||||
(80, 155, 215, 285),
|
||||
(245, 155, 220, 285),
|
||||
(410, 155, 225, 285),
|
||||
(590, 155, 225, 285),
|
||||
(755, 155, 230, 285),
|
||||
(940, 155, 265, 285),
|
||||
(1120, 155, 230, 285),
|
||||
(1285, 155, 225, 285),
|
||||
(1450, 155, 225, 285),
|
||||
(1595, 155, 205, 285),
|
||||
],
|
||||
"middle": [
|
||||
(275, 510, 350, 340),
|
||||
(550, 510, 340, 340),
|
||||
(835, 510, 330, 340),
|
||||
(1085, 510, 335, 340),
|
||||
(1345, 510, 350, 340),
|
||||
],
|
||||
"lower": [
|
||||
(590, 765, 370, 330),
|
||||
(835, 765, 340, 330),
|
||||
(1085, 765, 370, 330),
|
||||
],
|
||||
},
|
||||
"unit-rebel-archer": {
|
||||
"attack": [
|
||||
(88, 150, 220, 280),
|
||||
(260, 150, 220, 280),
|
||||
(455, 150, 245, 280),
|
||||
(650, 150, 250, 280),
|
||||
(850, 150, 260, 280),
|
||||
(1040, 150, 260, 280),
|
||||
(1255, 150, 260, 280),
|
||||
(1435, 150, 245, 280),
|
||||
(1600, 150, 230, 280),
|
||||
(1600, 150, 230, 280),
|
||||
],
|
||||
"middle": [
|
||||
(270, 475, 340, 330),
|
||||
(570, 475, 330, 330),
|
||||
(850, 475, 330, 330),
|
||||
(1130, 475, 340, 330),
|
||||
(1445, 475, 360, 330),
|
||||
],
|
||||
"lower": [
|
||||
(555, 735, 380, 320),
|
||||
(835, 735, 350, 320),
|
||||
(1120, 735, 390, 320),
|
||||
],
|
||||
},
|
||||
"unit-rebel-cavalry": {
|
||||
"attack": [
|
||||
(80, 154, 300, 286),
|
||||
(260, 154, 300, 286),
|
||||
(445, 154, 315, 286),
|
||||
(620, 154, 315, 286),
|
||||
(800, 154, 330, 286),
|
||||
(980, 154, 330, 286),
|
||||
(1145, 154, 320, 286),
|
||||
(1300, 154, 300, 286),
|
||||
(1460, 154, 285, 286),
|
||||
(1600, 154, 260, 286),
|
||||
],
|
||||
"middle": [
|
||||
(340, 510, 420, 340),
|
||||
(600, 510, 400, 340),
|
||||
(850, 510, 400, 340),
|
||||
(1095, 510, 400, 340),
|
||||
(1340, 510, 420, 340),
|
||||
],
|
||||
"lower": [
|
||||
(590, 765, 460, 330),
|
||||
(850, 765, 400, 330),
|
||||
(1120, 765, 430, 330),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def keyed_rgba(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
arr = np.array(rgba)
|
||||
rgb = arr[:, :, :3].astype(np.int16)
|
||||
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
|
||||
key = (r > 185) & (b > 185) & (g < 125)
|
||||
near_key = (r > 145) & (b > 135) & (g < 150) & ((r + b - g * 2) > 180)
|
||||
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
|
||||
|
||||
body = alpha > 0
|
||||
magenta_fringe = body & (r > 155) & (b > 135) & (g < 130)
|
||||
arr[:, :, 0] = np.where(magenta_fringe, np.minimum(arr[:, :, 0], 72), arr[:, :, 0])
|
||||
arr[:, :, 2] = np.where(magenta_fringe, np.minimum(arr[:, :, 2], 72), arr[:, :, 2])
|
||||
arr[:, :, 3] = alpha
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def connected_components(alpha: np.ndarray) -> list[tuple[int, int, int, int, int, float, float]]:
|
||||
height, width = alpha.shape
|
||||
visited = np.zeros_like(alpha, dtype=bool)
|
||||
components: list[tuple[int, int, int, int, int, float, float]] = []
|
||||
|
||||
for start_y in range(height):
|
||||
for start_x in range(width):
|
||||
if visited[start_y, start_x] or not alpha[start_y, start_x]:
|
||||
continue
|
||||
|
||||
stack = [(start_x, start_y)]
|
||||
visited[start_y, start_x] = True
|
||||
xs: list[int] = []
|
||||
ys: list[int] = []
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
for ny in (y - 1, y, y + 1):
|
||||
if ny < 0 or ny >= height:
|
||||
continue
|
||||
for nx in (x - 1, x, x + 1):
|
||||
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
|
||||
continue
|
||||
visited[ny, nx] = True
|
||||
stack.append((nx, ny))
|
||||
|
||||
area = len(xs)
|
||||
if area < 18:
|
||||
continue
|
||||
left, right = min(xs), max(xs)
|
||||
top, bottom = min(ys), max(ys)
|
||||
components.append((area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2))
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def connected_component_masks(
|
||||
alpha: np.ndarray,
|
||||
) -> list[tuple[tuple[int, int, int, int, int, float, float], np.ndarray]]:
|
||||
height, width = alpha.shape
|
||||
visited = np.zeros_like(alpha, dtype=bool)
|
||||
components: list[tuple[tuple[int, int, int, int, int, float, float], np.ndarray]] = []
|
||||
|
||||
for start_y in range(height):
|
||||
for start_x in range(width):
|
||||
if visited[start_y, start_x] or not alpha[start_y, start_x]:
|
||||
continue
|
||||
|
||||
stack = [(start_x, start_y)]
|
||||
visited[start_y, start_x] = True
|
||||
xs: list[int] = []
|
||||
ys: list[int] = []
|
||||
mask = np.zeros_like(alpha, dtype=bool)
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
mask[y, x] = True
|
||||
for ny in (y - 1, y, y + 1):
|
||||
if ny < 0 or ny >= height:
|
||||
continue
|
||||
for nx in (x - 1, x, x + 1):
|
||||
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
|
||||
continue
|
||||
visited[ny, nx] = True
|
||||
stack.append((nx, ny))
|
||||
|
||||
area = len(xs)
|
||||
if area < 18:
|
||||
continue
|
||||
left, right = min(xs), max(xs)
|
||||
top, bottom = min(ys), max(ys)
|
||||
component = (area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2)
|
||||
components.append((component, mask))
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def keep_subject_components(image: Image.Image) -> Image.Image:
|
||||
arr = np.array(image.convert("RGBA"))
|
||||
alpha = arr[:, :, 3] > 0
|
||||
height, width = alpha.shape
|
||||
components = connected_components(alpha)
|
||||
if not components:
|
||||
return image
|
||||
|
||||
image_center_x = width / 2
|
||||
image_center_y = height * 0.60
|
||||
anchor = max(
|
||||
components,
|
||||
key=lambda component: component[0]
|
||||
- abs(component[5] - image_center_x) * 2.6
|
||||
- abs(component[6] - image_center_y) * 1.2,
|
||||
)
|
||||
anchor_area, left, top, right, bottom, anchor_cx, _ = anchor
|
||||
expanded = (left - 92, top - 92, right + 92, bottom + 76)
|
||||
|
||||
keep = np.zeros_like(alpha, dtype=bool)
|
||||
for component in components:
|
||||
area, c_left, c_top, c_right, c_bottom, cx, cy = component
|
||||
near_anchor = (
|
||||
c_right >= expanded[0]
|
||||
and c_left <= expanded[2]
|
||||
and c_bottom >= expanded[1]
|
||||
and c_top <= expanded[3]
|
||||
)
|
||||
central = 0.015 * width <= cx <= 0.985 * width
|
||||
substantial = area >= max(22, anchor_area * 0.006)
|
||||
edge_fragment = (
|
||||
component != anchor
|
||||
and (c_left <= 1 or c_right >= width - 2 or c_top <= 1 or c_bottom >= height - 2)
|
||||
and area < anchor_area * 0.45
|
||||
)
|
||||
far_fragment = component != anchor and abs(cx - anchor_cx) > width * 0.30 and area < anchor_area * 0.75
|
||||
if component == anchor or (near_anchor and central and substantial and not edge_fragment and not far_fragment):
|
||||
keep[c_top : c_bottom + 1, c_left : c_right + 1] |= alpha[c_top : c_bottom + 1, c_left : c_right + 1]
|
||||
|
||||
arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def crop_subject(image: Image.Image) -> Image.Image:
|
||||
rgba = keep_subject_components(keyed_rgba(image))
|
||||
alpha = np.array(rgba.getchannel("A"))
|
||||
ys, xs = np.where(alpha > 0)
|
||||
if len(xs) == 0:
|
||||
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
pad = 9
|
||||
left = max(int(xs.min()) - pad, 0)
|
||||
top = max(int(ys.min()) - pad, 0)
|
||||
right = min(int(xs.max()) + pad + 1, rgba.width)
|
||||
bottom = min(int(ys.max()) + pad + 1, rgba.height)
|
||||
return rgba.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def quantize_alpha(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
arr = np.array(rgba)
|
||||
arr[:, :, 3] = np.where(arr[:, :, 3] > 24, 255, 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def fit_subject(source: Image.Image, spec: UnitSpec, scale_bias: float = 1.0) -> Image.Image:
|
||||
subject = crop_subject(source)
|
||||
if subject.width <= 1 or subject.height <= 1:
|
||||
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
|
||||
max_width = min(FRAME - 4, round(spec.max_width * scale_bias))
|
||||
max_height = min(FRAME - 4, round(spec.max_height * scale_bias))
|
||||
scale = min(max_width / subject.width, max_height / subject.height)
|
||||
size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale)))
|
||||
subject = subject.resize(size, Image.Resampling.LANCZOS)
|
||||
stretched_width = min(max_width, max(1, round(subject.width * spec.stretch_x)))
|
||||
if stretched_width != subject.width:
|
||||
subject = subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS)
|
||||
subject = quantize_alpha(subject)
|
||||
|
||||
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
x = (FRAME - subject.width) // 2
|
||||
y = spec.bottom - subject.height
|
||||
frame.alpha_composite(subject, (x, y))
|
||||
return quantize_alpha(keep_subject_components(frame))
|
||||
|
||||
|
||||
def compact_frame_width(frame: Image.Image, max_content_width: int) -> Image.Image:
|
||||
rgba = frame.convert("RGBA")
|
||||
alpha = np.array(rgba.getchannel("A"))
|
||||
ys, xs = np.where(alpha > 0)
|
||||
if len(xs) == 0:
|
||||
return rgba
|
||||
|
||||
left = int(xs.min())
|
||||
right = int(xs.max()) + 1
|
||||
top = int(ys.min())
|
||||
bottom = int(ys.max()) + 1
|
||||
content = rgba.crop((left, top, right, bottom))
|
||||
if content.width <= max_content_width:
|
||||
return rgba
|
||||
|
||||
content = content.resize((max_content_width, content.height), Image.Resampling.LANCZOS)
|
||||
content = quantize_alpha(content)
|
||||
out = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
out.alpha_composite(content, ((FRAME - max_content_width) // 2, bottom - content.height))
|
||||
return quantize_alpha(keep_subject_components(out))
|
||||
|
||||
|
||||
def compact_walk_frame(frame: Image.Image, spec: UnitSpec) -> Image.Image:
|
||||
if spec.key == "unit-guan-yu":
|
||||
return compact_frame_width(frame, 230)
|
||||
if spec.key == "unit-zhang-fei":
|
||||
return compact_frame_width(frame, 220)
|
||||
return frame
|
||||
|
||||
|
||||
def crop_grid(image: Image.Image, row: int, col: int, rows: int, cols: int, x_pad: int = 0, y_pad: int = 0) -> Image.Image:
|
||||
cell_w = image.width / cols
|
||||
cell_h = image.height / rows
|
||||
left = max(0, int(round(col * cell_w)) - x_pad)
|
||||
top = max(0, int(round(row * cell_h)) - y_pad)
|
||||
right = min(image.width, int(round((col + 1) * cell_w)) + x_pad)
|
||||
bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad)
|
||||
return image.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def crop_row(image: Image.Image, row: int, rows: int, y_pad: int = 0) -> Image.Image:
|
||||
cell_h = image.height / rows
|
||||
top = max(0, int(round(row * cell_h)) - y_pad)
|
||||
bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad)
|
||||
return image.crop((0, top, image.width, bottom))
|
||||
|
||||
|
||||
def crop_center_xy(image: Image.Image, center_x: int, center_y: int, width: int, height: int) -> Image.Image:
|
||||
left = max(0, center_x - width // 2)
|
||||
top = max(0, center_y - height // 2)
|
||||
right = min(image.width, center_x + width // 2)
|
||||
bottom = min(image.height, center_y + height // 2)
|
||||
return image.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def tightened_crop(crop: tuple[int, int, int, int], spec: UnitSpec, group: str) -> tuple[int, int, int, int]:
|
||||
center_x, center_y, width, height = crop
|
||||
if group == "attack":
|
||||
return center_x, center_y, width, height
|
||||
return center_x, center_y, width, height
|
||||
|
||||
|
||||
def action_fit_spec(spec: UnitSpec, group: str) -> UnitSpec:
|
||||
if group != "attack":
|
||||
return spec
|
||||
attack_widths = {
|
||||
"unit-guan-yu": 274,
|
||||
"unit-zhang-fei": 276,
|
||||
"unit-rebel-cavalry": 286,
|
||||
}
|
||||
max_width = attack_widths.get(spec.key)
|
||||
if max_width is None:
|
||||
return spec
|
||||
return replace(spec, max_width=max_width)
|
||||
|
||||
|
||||
def extract_row_subjects(image: Image.Image, row: int, rows: int, expected: int, y_pad: int = 0) -> list[Image.Image]:
|
||||
row_image = keyed_rgba(crop_row(image, row, rows, y_pad))
|
||||
arr = np.array(row_image.convert("RGBA"))
|
||||
alpha = arr[:, :, 3] > 0
|
||||
component_items = connected_component_masks(alpha)
|
||||
if not component_items:
|
||||
return [crop_grid(image, row, col, rows, expected) for col in range(expected)]
|
||||
|
||||
width = row_image.width
|
||||
slot_width = width / expected
|
||||
slot_centers = [(slot + 0.5) * slot_width for slot in range(expected)]
|
||||
|
||||
def body_score(component: tuple[int, int, int, int, int, float, float], slot_center: float) -> float:
|
||||
area, left, top, right, bottom, cx, _ = component
|
||||
component_width = right - left + 1
|
||||
component_height = bottom - top + 1
|
||||
aspect = component_width / max(1, component_height)
|
||||
broadness = min(1.25, max(0.18, aspect))
|
||||
slender_penalty = 0.25 if component_width < 18 or aspect < 0.16 else 1.0
|
||||
edge_penalty = 0.55 if left <= 1 or right >= width - 2 else 1.0
|
||||
return area * broadness * slender_penalty * edge_penalty - abs(cx - slot_center) * 42
|
||||
|
||||
anchors: list[int] = []
|
||||
used: set[int] = set()
|
||||
indexed_components = list(enumerate(component_items))
|
||||
for slot_center in slot_centers:
|
||||
candidates = [
|
||||
(index, item)
|
||||
for index, item in indexed_components
|
||||
if index not in used and item[0][0] >= 90
|
||||
]
|
||||
if not candidates:
|
||||
anchors.append(max(range(len(component_items)), key=lambda index: body_score(component_items[index][0], slot_center)))
|
||||
continue
|
||||
index, _ = max(candidates, key=lambda item: body_score(item[1][0], slot_center))
|
||||
used.add(index)
|
||||
anchors.append(index)
|
||||
|
||||
masks = [np.zeros_like(alpha, dtype=bool) for _ in range(expected)]
|
||||
for component, component_mask in component_items:
|
||||
area, left, top, right, bottom, cx, _ = component
|
||||
if area < 28:
|
||||
continue
|
||||
slot = min(range(expected), key=lambda index: abs(cx - component_items[anchors[index]][0][5]))
|
||||
anchor_area, anchor_left, anchor_top, anchor_right, anchor_bottom, anchor_cx, _ = component_items[anchors[slot]][0]
|
||||
expanded = (
|
||||
anchor_left - slot_width * 0.78,
|
||||
anchor_top - 96,
|
||||
anchor_right + slot_width * 0.78,
|
||||
anchor_bottom + 96,
|
||||
)
|
||||
near_anchor = right >= expanded[0] and left <= expanded[2] and bottom >= expanded[1] and top <= expanded[3]
|
||||
likely_neighbor_fragment = (
|
||||
component != component_items[anchors[slot]][0]
|
||||
and (left <= 2 or right >= width - 3)
|
||||
and area < max(700, anchor_area * 0.18)
|
||||
)
|
||||
if not near_anchor or likely_neighbor_fragment:
|
||||
continue
|
||||
masks[slot] |= component_mask
|
||||
|
||||
subjects: list[Image.Image] = []
|
||||
for slot, mask in enumerate(masks):
|
||||
ys, xs = np.where(mask)
|
||||
if len(xs) == 0:
|
||||
subjects.append(crop_grid(image, row, slot, rows, expected))
|
||||
continue
|
||||
pad = 10
|
||||
left = max(int(xs.min()) - pad, 0)
|
||||
top = max(int(ys.min()) - pad, 0)
|
||||
right = min(int(xs.max()) + pad + 1, row_image.width)
|
||||
bottom = min(int(ys.max()) + pad + 1, row_image.height)
|
||||
subject_arr = np.array(row_image.crop((left, top, right, bottom)).convert("RGBA"))
|
||||
subject_mask = mask[top:bottom, left:right]
|
||||
subject_arr[:, :, 3] = np.where(subject_mask, subject_arr[:, :, 3], 0).astype(np.uint8)
|
||||
subjects.append(Image.fromarray(subject_arr, "RGBA"))
|
||||
|
||||
return subjects
|
||||
|
||||
|
||||
def build_base_sheet(base_source: Image.Image, spec: UnitSpec) -> Image.Image:
|
||||
rows: list[list[Image.Image]] = []
|
||||
for row in range(4):
|
||||
frames = [fit_subject(crop_grid(base_source, row, col, 4, 8, 0, 2), spec) for col in range(8)]
|
||||
idle = [frames[0].copy() for _ in range(8)]
|
||||
walk = [compact_walk_frame(frame, spec) for frame in frames]
|
||||
rows.append(idle + walk)
|
||||
|
||||
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
for row, frames in enumerate(rows):
|
||||
for col, frame in enumerate(frames):
|
||||
sheet.alpha_composite(frame, (col * FRAME, row * FRAME))
|
||||
return sheet
|
||||
|
||||
|
||||
def build_action_source_frames(action_source: Image.Image, spec: UnitSpec) -> dict[str, list[Image.Image]]:
|
||||
layout = ACTION_CROPS_V2.get(spec.key)
|
||||
if layout:
|
||||
attack_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "attack")) for crop in layout["attack"]]
|
||||
middle_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "middle")) for crop in layout["middle"]]
|
||||
lower_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "lower")) for crop in layout["lower"]]
|
||||
else:
|
||||
attack_sources = [crop_grid(action_source, 0, col, 3, 10, 0, 18) for col in range(10)]
|
||||
middle_sources = [crop_grid(action_source, 1, col, 3, 5, 0, 18) for col in range(5)]
|
||||
lower_sources = [crop_grid(action_source, 2, col, 3, 3, 0, 12) for col in range(3)]
|
||||
|
||||
attack_frames = [fit_subject(source, action_fit_spec(spec, "attack")) for source in attack_sources]
|
||||
middle = [fit_subject(source, spec) for source in middle_sources]
|
||||
lower = [fit_subject(source, spec) for source in lower_sources]
|
||||
|
||||
return {
|
||||
"attack": attack_frames,
|
||||
"strategy": [middle[index % len(middle)] for index in (0, 1, 2, 1, 0, 1, 2, 1)],
|
||||
"item": [middle[index % len(middle)] for index in (3, 4, 3, 4, 3, 4, 3, 4)],
|
||||
"hurt": [lower[0] for _ in range(4)],
|
||||
"celebrate": [lower[1], lower[2], lower[1], lower[2], lower[1], lower[2]],
|
||||
}
|
||||
|
||||
|
||||
def direction_frame(frame: Image.Image, direction: str) -> Image.Image:
|
||||
if direction == "west":
|
||||
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
return frame
|
||||
|
||||
|
||||
def build_action_sheet(action_source: Image.Image, spec: UnitSpec) -> Image.Image:
|
||||
source_frames = build_action_source_frames(action_source, spec)
|
||||
sheet = Image.new("RGBA", (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
for row, direction in enumerate(DIRECTIONS):
|
||||
for action, frames in source_frames.items():
|
||||
offset = ACTION_OFFSETS[action]
|
||||
for index, frame in enumerate(frames):
|
||||
sheet.alpha_composite(direction_frame(frame, direction), ((offset + index) * FRAME, row * FRAME))
|
||||
return sheet
|
||||
|
||||
|
||||
def representative_base_frames(base_sheet: Image.Image) -> list[Image.Image]:
|
||||
picks = [(0, 0), (0, 10), (1, 10), (2, 10), (3, 10)]
|
||||
return [base_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for row, col in picks]
|
||||
|
||||
|
||||
def representative_action_frames(action_sheet: Image.Image) -> list[Image.Image]:
|
||||
picks = [
|
||||
(1, 0),
|
||||
(1, 3),
|
||||
(1, 6),
|
||||
(0, ACTION_OFFSETS["strategy"] + 2),
|
||||
(0, ACTION_OFFSETS["item"] + 1),
|
||||
(0, ACTION_OFFSETS["hurt"]),
|
||||
(0, ACTION_OFFSETS["celebrate"] + 1),
|
||||
]
|
||||
return [action_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for row, col in picks]
|
||||
|
||||
|
||||
def checker_background(size: tuple[int, int], tile: int = 16) -> Image.Image:
|
||||
image = Image.new("RGBA", size, (64, 84, 53, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], tile):
|
||||
for x in range(0, size[0], tile):
|
||||
color = (75, 97, 60, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (55, 72, 47, 255)
|
||||
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
|
||||
return image
|
||||
|
||||
|
||||
def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
|
||||
bg = checker_background((size, size), 8)
|
||||
scaled = frame.resize((size, size), Image.Resampling.LANCZOS)
|
||||
bg.alpha_composite(scaled, (0, 0))
|
||||
return bg
|
||||
|
||||
|
||||
def save_contact_sheet(spec: UnitSpec, base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
|
||||
thumbs = representative_base_frames(base_sheet) + representative_action_frames(action_sheet)
|
||||
labels = ["idle", "walk S", "walk E", "walk N", "walk W", "atk 1", "atk 4", "atk 7", "cmd", "item", "hurt", "win"]
|
||||
thumb_size = 132
|
||||
pad = 18
|
||||
label_h = 24
|
||||
cols = 6
|
||||
rows = 2
|
||||
out = Image.new("RGBA", (pad + cols * (thumb_size + pad), pad + rows * (thumb_size + label_h + pad)), (20, 24, 22, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
font = ImageFont.load_default()
|
||||
draw.text((pad, 4), spec.label, fill=(240, 226, 178, 255), font=font)
|
||||
for index, frame in enumerate(thumbs):
|
||||
x = pad + (index % cols) * (thumb_size + pad)
|
||||
y = 22 + pad + (index // cols) * (thumb_size + label_h + pad)
|
||||
tile = draw_frame_on_bg(frame, thumb_size)
|
||||
out.alpha_composite(tile, (x, y))
|
||||
draw.text((x, y + thumb_size + 5), labels[index], fill=(238, 224, 178, 255), font=font)
|
||||
path = DOCS_DIR / f"handpaint-batch1-{spec.key}-contact.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_before_after(spec: UnitSpec, base_sheet: Image.Image) -> Path:
|
||||
before_path = WORK_DIR / f"before-{spec.key}.png"
|
||||
before = Image.open(before_path).convert("RGBA").crop((0, 0, FRAME, FRAME))
|
||||
after = base_sheet.crop((0, 0, FRAME, FRAME))
|
||||
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
font = ImageFont.load_default()
|
||||
draw.text((30, 18), f"{spec.label} Before / After at 313px", fill=(238, 224, 178, 255), font=font)
|
||||
out.alpha_composite(draw_frame_on_bg(before, 160), (30, 48))
|
||||
out.alpha_composite(draw_frame_on_bg(after, 160), (220, 48))
|
||||
draw.text((30, 218), "Before / After at battle scale", fill=(238, 224, 178, 255), font=font)
|
||||
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
|
||||
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
|
||||
draw.text((30, 330), "left: previous asset, right: new hand-painted batch 1 asset", fill=(172, 185, 166, 255), font=font)
|
||||
path = DOCS_DIR / f"handpaint-batch1-{spec.key}-before-after.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_animation_gif(spec: UnitSpec, base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
|
||||
frames: list[Image.Image] = []
|
||||
for index in range(10):
|
||||
canvas = checker_background((540, 190), 10)
|
||||
idle = base_sheet.crop(((index % 8) * FRAME, 0, ((index % 8) + 1) * FRAME, FRAME))
|
||||
walk = base_sheet.crop(((8 + index % 8) * FRAME, FRAME, (9 + index % 8) * FRAME, 2 * FRAME))
|
||||
attack = action_sheet.crop((index * FRAME, FRAME, (index + 1) * FRAME, 2 * FRAME))
|
||||
for x, frame in ((22, idle), (190, walk), (358, attack)):
|
||||
scaled = frame.resize((150, 150), Image.Resampling.LANCZOS)
|
||||
canvas.alpha_composite(scaled, (x, 25))
|
||||
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
|
||||
|
||||
path = DOCS_DIR / f"handpaint-batch1-{spec.key}-animation.gif"
|
||||
frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=110, loop=0)
|
||||
return path
|
||||
|
||||
|
||||
def copy_before_once(spec: UnitSpec) -> None:
|
||||
for suffix in ("", "-actions"):
|
||||
source = UNIT_DIR / f"{spec.key}{suffix}.png"
|
||||
target = WORK_DIR / f"before-{spec.key}{suffix}.png"
|
||||
if not target.exists():
|
||||
target.write_bytes(source.read_bytes())
|
||||
|
||||
|
||||
def validate_sheet(path: Path, expected_size: tuple[int, int]) -> tuple[tuple[int, int], int, int]:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
if image.size != expected_size:
|
||||
raise ValueError(f"{path.name}: expected {expected_size}, got {image.size}")
|
||||
alpha = np.array(image.getchannel("A"))
|
||||
partial = int(np.count_nonzero((alpha > 0) & (alpha < 255)))
|
||||
opaque = int(np.count_nonzero(alpha == 255))
|
||||
if partial:
|
||||
raise ValueError(f"{path.name}: found {partial} partially transparent pixels")
|
||||
if opaque <= 0:
|
||||
raise ValueError(f"{path.name}: no opaque subject pixels found")
|
||||
return image.size, partial, opaque
|
||||
|
||||
|
||||
def save_overview(processed: list[tuple[UnitSpec, Image.Image, Image.Image]]) -> Path:
|
||||
thumb = 96
|
||||
pad = 14
|
||||
label_h = 20
|
||||
cols = 5
|
||||
rows = 3
|
||||
out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad)), (19, 23, 21, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
font = ImageFont.load_default()
|
||||
for col, (spec, base_sheet, action_sheet) in enumerate(processed):
|
||||
frames = [
|
||||
base_sheet.crop((0, 0, FRAME, FRAME)),
|
||||
base_sheet.crop((10 * FRAME, FRAME, 11 * FRAME, 2 * FRAME)),
|
||||
action_sheet.crop((3 * FRAME, FRAME, 4 * FRAME, 2 * FRAME)),
|
||||
]
|
||||
for row, frame in enumerate(frames):
|
||||
x = pad + col * (thumb + pad)
|
||||
y = pad + row * (thumb + label_h + pad)
|
||||
out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y))
|
||||
if row == 0:
|
||||
draw.text((x, y + thumb + 4), spec.key.replace("unit-", ""), fill=(238, 224, 178, 255), font=font)
|
||||
path = DOCS_DIR / "handpaint-batch1-overview-contact.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
processed: list[tuple[UnitSpec, Image.Image, Image.Image]] = []
|
||||
alpha_lines = ["# Handpaint Batch 1 Alpha Validation", ""]
|
||||
|
||||
for spec in UNIT_SPECS:
|
||||
copy_before_once(spec)
|
||||
base_source = Image.open(WORK_DIR / f"source-{spec.key}-base-motion.png").convert("RGBA")
|
||||
action_source = Image.open(WORK_DIR / f"source-{spec.key}-action.png").convert("RGBA")
|
||||
|
||||
base_sheet = build_base_sheet(base_source, spec)
|
||||
action_sheet = build_action_sheet(action_source, spec)
|
||||
|
||||
base_path = UNIT_DIR / f"{spec.key}.png"
|
||||
action_path = UNIT_DIR / f"{spec.key}-actions.png"
|
||||
base_sheet.save(base_path, optimize=True)
|
||||
action_sheet.save(action_path, optimize=True)
|
||||
|
||||
contact_path = save_contact_sheet(spec, base_sheet, action_sheet)
|
||||
before_after_path = save_before_after(spec, base_sheet)
|
||||
animation_path = save_animation_gif(spec, base_sheet, action_sheet)
|
||||
|
||||
base_result = validate_sheet(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
|
||||
action_result = validate_sheet(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
|
||||
alpha_lines.extend(
|
||||
[
|
||||
f"## {spec.key}",
|
||||
f"- base: size {base_result[0]}, partial alpha {base_result[1]}, opaque pixels {base_result[2]}",
|
||||
f"- action: size {action_result[0]}, partial alpha {action_result[1]}, opaque pixels {action_result[2]}",
|
||||
f"- contact: `{contact_path.name}`",
|
||||
f"- animation: `{animation_path.name}`",
|
||||
f"- before/after: `{before_after_path.name}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
processed.append((spec, base_sheet, action_sheet))
|
||||
print(f"Wrote {base_path}")
|
||||
print(f"Wrote {action_path}")
|
||||
print(f"Wrote {contact_path}")
|
||||
print(f"Wrote {before_after_path}")
|
||||
print(f"Wrote {animation_path}")
|
||||
|
||||
overview_path = save_overview(processed)
|
||||
alpha_report = DOCS_DIR / "handpaint-batch1-alpha-report.md"
|
||||
alpha_report.write_text("\n".join(alpha_lines), encoding="utf-8")
|
||||
print(f"Wrote {overview_path}")
|
||||
print(f"Wrote {alpha_report}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
395
scripts/assemble-liu-bei-handpaint-sample.py
Normal file
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WORK_DIR = ROOT / "tmp" / "liu-bei-handpaint-sample"
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
|
||||
FRAME = 313
|
||||
DIRECTIONS = ("south", "east", "north", "west")
|
||||
BASE_FRAMES_PER_DIRECTION = 16
|
||||
ACTION_COUNTS = {
|
||||
"attack": 10,
|
||||
"strategy": 8,
|
||||
"item": 8,
|
||||
"hurt": 4,
|
||||
"celebrate": 6,
|
||||
}
|
||||
ACTION_OFFSETS = {
|
||||
"attack": 0,
|
||||
"strategy": 10,
|
||||
"item": 18,
|
||||
"hurt": 26,
|
||||
"celebrate": 30,
|
||||
}
|
||||
ACTION_FRAMES_PER_DIRECTION = sum(ACTION_COUNTS.values())
|
||||
|
||||
|
||||
def keyed_rgba(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
arr = np.array(rgba)
|
||||
rgb = arr[:, :, :3].astype(np.int16)
|
||||
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
|
||||
key = (r > 185) & (b > 185) & (g < 125)
|
||||
near_key = (r > 145) & (b > 135) & (g < 150) & ((r + b - g * 2) > 180)
|
||||
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
|
||||
|
||||
body = alpha > 0
|
||||
magenta_fringe = body & (r > 155) & (b > 135) & (g < 130)
|
||||
arr[:, :, 0] = np.where(magenta_fringe, np.minimum(arr[:, :, 0], 70), arr[:, :, 0])
|
||||
arr[:, :, 2] = np.where(magenta_fringe, np.minimum(arr[:, :, 2], 70), arr[:, :, 2])
|
||||
arr[:, :, 3] = alpha
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def crop_subject(image: Image.Image) -> Image.Image:
|
||||
rgba = keyed_rgba(image)
|
||||
rgba = keep_subject_components(rgba)
|
||||
alpha = np.array(rgba.getchannel("A"))
|
||||
ys, xs = np.where(alpha > 0)
|
||||
if len(xs) == 0:
|
||||
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
pad = 8
|
||||
left = max(int(xs.min()) - pad, 0)
|
||||
top = max(int(ys.min()) - pad, 0)
|
||||
right = min(int(xs.max()) + pad + 1, rgba.width)
|
||||
bottom = min(int(ys.max()) + pad + 1, rgba.height)
|
||||
return rgba.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def keep_subject_components(image: Image.Image) -> Image.Image:
|
||||
arr = np.array(image.convert("RGBA"))
|
||||
alpha = arr[:, :, 3] > 0
|
||||
height, width = alpha.shape
|
||||
visited = np.zeros_like(alpha, dtype=bool)
|
||||
components: list[tuple[int, int, int, int, int, float, float]] = []
|
||||
|
||||
for start_y in range(height):
|
||||
for start_x in range(width):
|
||||
if visited[start_y, start_x] or not alpha[start_y, start_x]:
|
||||
continue
|
||||
|
||||
stack = [(start_x, start_y)]
|
||||
visited[start_y, start_x] = True
|
||||
xs: list[int] = []
|
||||
ys: list[int] = []
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
for ny in (y - 1, y, y + 1):
|
||||
if ny < 0 or ny >= height:
|
||||
continue
|
||||
for nx in (x - 1, x, x + 1):
|
||||
if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]:
|
||||
continue
|
||||
visited[ny, nx] = True
|
||||
stack.append((nx, ny))
|
||||
|
||||
area = len(xs)
|
||||
if area < 24:
|
||||
continue
|
||||
left, right = min(xs), max(xs)
|
||||
top, bottom = min(ys), max(ys)
|
||||
components.append((area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2))
|
||||
|
||||
if not components:
|
||||
return image
|
||||
|
||||
image_center_x = width / 2
|
||||
image_center_y = height * 0.58
|
||||
anchor = max(
|
||||
components,
|
||||
key=lambda component: component[0]
|
||||
- abs(component[5] - image_center_x) * 3
|
||||
- abs(component[6] - image_center_y) * 1.4,
|
||||
)
|
||||
anchor_area, left, top, right, bottom, anchor_cx, anchor_cy = anchor
|
||||
expanded = (left - 70, top - 80, right + 70, bottom + 65)
|
||||
|
||||
keep = np.zeros_like(alpha, dtype=bool)
|
||||
for component in components:
|
||||
area, c_left, c_top, c_right, c_bottom, cx, cy = component
|
||||
touches_cell_edge = c_left <= 2 or c_right >= width - 3 or c_top <= 2 or c_bottom >= height - 3
|
||||
if component != anchor and touches_cell_edge and area < anchor_area * 0.55:
|
||||
continue
|
||||
near_anchor = (
|
||||
c_right >= expanded[0]
|
||||
and c_left <= expanded[2]
|
||||
and c_bottom >= expanded[1]
|
||||
and c_top <= expanded[3]
|
||||
)
|
||||
central = 0.04 * width <= cx <= 0.96 * width
|
||||
substantial = area >= max(55, anchor_area * 0.025)
|
||||
if component == anchor or (near_anchor and central and substantial):
|
||||
keep[c_top : c_bottom + 1, c_left : c_right + 1] |= alpha[c_top : c_bottom + 1, c_left : c_right + 1]
|
||||
|
||||
arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def quantize_alpha(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
arr = np.array(rgba)
|
||||
arr[:, :, 3] = np.where(arr[:, :, 3] > 24, 255, 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def fit_subject(
|
||||
source: Image.Image,
|
||||
max_width: int = 286,
|
||||
max_height: int = 300,
|
||||
bottom: int = 306,
|
||||
stretch_x: float = 1.18,
|
||||
) -> Image.Image:
|
||||
subject = crop_subject(source)
|
||||
if subject.width <= 1 or subject.height <= 1:
|
||||
return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
|
||||
scale = min(max_width / subject.width, max_height / subject.height)
|
||||
size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale)))
|
||||
subject = subject.resize(size, Image.Resampling.LANCZOS)
|
||||
stretched_width = min(max_width, max(1, round(subject.width * stretch_x)))
|
||||
if stretched_width != subject.width:
|
||||
subject = subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS)
|
||||
subject = quantize_alpha(subject)
|
||||
|
||||
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
x = (FRAME - subject.width) // 2
|
||||
y = bottom - subject.height
|
||||
frame.alpha_composite(subject, (x, y))
|
||||
frame = quantize_alpha(frame)
|
||||
return quantize_alpha(keep_subject_components(frame))
|
||||
|
||||
|
||||
def crop_grid(image: Image.Image, row: int, col: int, rows: int, cols: int, x_pad: int = 0, y_pad: int = 0) -> Image.Image:
|
||||
cell_w = image.width / cols
|
||||
cell_h = image.height / rows
|
||||
left = max(0, int(round(col * cell_w)) - x_pad)
|
||||
top = max(0, int(round(row * cell_h)) - y_pad)
|
||||
right = min(image.width, int(round((col + 1) * cell_w)) + x_pad)
|
||||
bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad)
|
||||
return image.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def crop_center(image: Image.Image, center_x: int, center_y: int, width: int, height: int) -> Image.Image:
|
||||
left = max(0, center_x - width // 2)
|
||||
top = max(0, center_y - height // 2)
|
||||
right = min(image.width, center_x + width // 2)
|
||||
bottom = min(image.height, center_y + height // 2)
|
||||
return image.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def build_base_sheet(base_source: Image.Image) -> Image.Image:
|
||||
rows: list[list[Image.Image]] = []
|
||||
for row in range(4):
|
||||
frames = [fit_subject(crop_grid(base_source, row, col, 4, 8, 0, 2)) for col in range(8)]
|
||||
idle = [frames[0].copy() for _ in range(8)]
|
||||
walk = frames
|
||||
rows.append(idle + walk)
|
||||
|
||||
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
for row, frames in enumerate(rows):
|
||||
for col, frame in enumerate(frames):
|
||||
sheet.alpha_composite(frame, (col * FRAME, row * FRAME))
|
||||
return sheet
|
||||
|
||||
|
||||
def build_action_source_frames(action_source: Image.Image) -> dict[str, list[Image.Image]]:
|
||||
attack_frames = [
|
||||
fit_subject(crop_grid(action_source, 0, col, 3, 10, 10, 28), 300, 300, 306)
|
||||
for col in range(10)
|
||||
]
|
||||
|
||||
mid = [fit_subject(crop_grid(action_source, 1, col, 3, 5, 18, 26), 286, 300, 306) for col in range(5)]
|
||||
strategy_seed = mid[:3]
|
||||
item_seed = mid[3:]
|
||||
|
||||
lower_raw = [
|
||||
crop_center(action_source, 470, 805, 330, 310),
|
||||
crop_center(action_source, 790, 805, 330, 310),
|
||||
crop_center(action_source, 1100, 805, 330, 310),
|
||||
]
|
||||
lower = [fit_subject(frame, 286, 300, 306) for frame in lower_raw]
|
||||
|
||||
return {
|
||||
"attack": attack_frames,
|
||||
"strategy": [strategy_seed[index % len(strategy_seed)] for index in (0, 1, 2, 1, 0, 1, 2, 1)],
|
||||
"item": [item_seed[index % len(item_seed)] for index in range(8)],
|
||||
"hurt": [lower[0] for _ in range(4)],
|
||||
"celebrate": [lower[1], lower[2], lower[1], lower[2], lower[1], lower[2]],
|
||||
}
|
||||
|
||||
|
||||
def direction_frame(frame: Image.Image, direction: str) -> Image.Image:
|
||||
if direction == "west":
|
||||
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
return frame
|
||||
|
||||
|
||||
def build_action_sheet(action_source: Image.Image) -> Image.Image:
|
||||
source_frames = build_action_source_frames(action_source)
|
||||
sheet = Image.new("RGBA", (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
for row, direction in enumerate(DIRECTIONS):
|
||||
for action, frames in source_frames.items():
|
||||
offset = ACTION_OFFSETS[action]
|
||||
for index, frame in enumerate(frames):
|
||||
sheet.alpha_composite(direction_frame(frame, direction), ((offset + index) * FRAME, row * FRAME))
|
||||
return sheet
|
||||
|
||||
|
||||
def representative_base_frames(base_sheet: Image.Image) -> list[Image.Image]:
|
||||
picks = [
|
||||
("south idle", 0, 0),
|
||||
("south walk", 0, 10),
|
||||
("east walk", 1, 10),
|
||||
("north walk", 2, 10),
|
||||
("west walk", 3, 10),
|
||||
]
|
||||
return [base_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for _, row, col in picks]
|
||||
|
||||
|
||||
def representative_action_frames(action_sheet: Image.Image) -> list[Image.Image]:
|
||||
picks = [
|
||||
("attack 1", 1, 0),
|
||||
("attack 4", 1, 3),
|
||||
("attack 7", 1, 6),
|
||||
("strategy", 0, ACTION_OFFSETS["strategy"] + 2),
|
||||
("item", 0, ACTION_OFFSETS["item"] + 1),
|
||||
("hurt", 0, ACTION_OFFSETS["hurt"]),
|
||||
("celebrate", 0, ACTION_OFFSETS["celebrate"] + 1),
|
||||
]
|
||||
return [action_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for _, row, col in picks]
|
||||
|
||||
|
||||
def checker_background(size: tuple[int, int], tile: int = 16) -> Image.Image:
|
||||
image = Image.new("RGBA", size, (67, 88, 54, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], tile):
|
||||
for x in range(0, size[0], tile):
|
||||
color = (77, 99, 59, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (58, 76, 48, 255)
|
||||
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
|
||||
return image
|
||||
|
||||
|
||||
def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
|
||||
bg = checker_background((size, size), 8)
|
||||
scaled = frame.resize((size, size), Image.Resampling.LANCZOS)
|
||||
bg.alpha_composite(scaled, (0, 0))
|
||||
return bg
|
||||
|
||||
|
||||
def save_contact_sheet(base_sheet: Image.Image, action_sheet: Image.Image) -> None:
|
||||
thumbs = representative_base_frames(base_sheet) + representative_action_frames(action_sheet)
|
||||
labels = [
|
||||
"idle",
|
||||
"walk S",
|
||||
"walk E",
|
||||
"walk N",
|
||||
"walk W",
|
||||
"atk 1",
|
||||
"atk 4",
|
||||
"atk 7",
|
||||
"cmd",
|
||||
"item",
|
||||
"hurt",
|
||||
"win",
|
||||
]
|
||||
thumb_size = 132
|
||||
pad = 18
|
||||
label_h = 24
|
||||
cols = 6
|
||||
rows = 2
|
||||
out = Image.new("RGBA", (pad + cols * (thumb_size + pad), pad + rows * (thumb_size + label_h + pad)), (20, 24, 22, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
font = ImageFont.load_default()
|
||||
for index, frame in enumerate(thumbs):
|
||||
x = pad + (index % cols) * (thumb_size + pad)
|
||||
y = pad + (index // cols) * (thumb_size + label_h + pad)
|
||||
tile = draw_frame_on_bg(frame, thumb_size)
|
||||
out.alpha_composite(tile, (x, y))
|
||||
draw.text((x, y + thumb_size + 5), labels[index], fill=(238, 224, 178, 255), font=font)
|
||||
out.convert("RGB").save(DOCS_DIR / "liu-bei-handpaint-sample-contact.png", optimize=True)
|
||||
|
||||
|
||||
def save_before_after(base_sheet: Image.Image) -> None:
|
||||
before = Image.open(WORK_DIR / "before-unit-liu-bei.png").convert("RGBA").crop((0, 0, FRAME, FRAME))
|
||||
after = base_sheet.crop((0, 0, FRAME, FRAME))
|
||||
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
font = ImageFont.load_default()
|
||||
draw.text((30, 18), "Before / After at 313px", fill=(238, 224, 178, 255), font=font)
|
||||
out.alpha_composite(draw_frame_on_bg(before, 160), (30, 48))
|
||||
out.alpha_composite(draw_frame_on_bg(after, 160), (220, 48))
|
||||
draw.text((30, 218), "Before / After at battle scale", fill=(238, 224, 178, 255), font=font)
|
||||
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
|
||||
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
|
||||
draw.text((30, 330), "left: previous checked-in Liu Bei, right: new hand-painted sample", fill=(172, 185, 166, 255), font=font)
|
||||
out.convert("RGB").save(DOCS_DIR / "liu-bei-handpaint-sample-before-after.png", optimize=True)
|
||||
|
||||
|
||||
def save_animation_gif(base_sheet: Image.Image, action_sheet: Image.Image) -> None:
|
||||
frames: list[Image.Image] = []
|
||||
for index in range(10):
|
||||
canvas = checker_background((540, 190), 10)
|
||||
idle = base_sheet.crop(((index % 8) * FRAME, 0, ((index % 8) + 1) * FRAME, FRAME))
|
||||
walk = base_sheet.crop(((8 + index % 8) * FRAME, FRAME, (9 + index % 8) * FRAME, 2 * FRAME))
|
||||
attack = action_sheet.crop((index * FRAME, FRAME, (index + 1) * FRAME, 2 * FRAME))
|
||||
for x, frame in ((22, idle), (190, walk), (358, attack)):
|
||||
scaled = frame.resize((150, 150), Image.Resampling.LANCZOS)
|
||||
canvas.alpha_composite(scaled, (x, 25))
|
||||
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=128))
|
||||
frames[0].save(
|
||||
DOCS_DIR / "liu-bei-handpaint-sample-animation.gif",
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
optimize=True,
|
||||
duration=110,
|
||||
loop=0,
|
||||
)
|
||||
|
||||
|
||||
def validate_sheet(path: Path, expected_size: tuple[int, int]) -> None:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
if image.size != expected_size:
|
||||
raise ValueError(f"{path.name}: expected {expected_size}, got {image.size}")
|
||||
alpha = np.array(image.getchannel("A"))
|
||||
partial = np.count_nonzero((alpha > 0) & (alpha < 255))
|
||||
if partial:
|
||||
raise ValueError(f"{path.name}: found {partial} partially transparent pixels")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
base_source = Image.open(WORK_DIR / "source-base-motion.png").convert("RGBA")
|
||||
action_source = Image.open(WORK_DIR / "source-action.png").convert("RGBA")
|
||||
|
||||
base_sheet = build_base_sheet(base_source)
|
||||
action_sheet = build_action_sheet(action_source)
|
||||
|
||||
base_path = UNIT_DIR / "unit-liu-bei.png"
|
||||
action_path = UNIT_DIR / "unit-liu-bei-actions.png"
|
||||
base_sheet.save(base_path, optimize=True)
|
||||
action_sheet.save(action_path, optimize=True)
|
||||
|
||||
save_contact_sheet(base_sheet, action_sheet)
|
||||
save_before_after(base_sheet)
|
||||
save_animation_gif(base_sheet, action_sheet)
|
||||
|
||||
validate_sheet(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
|
||||
validate_sheet(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)))
|
||||
|
||||
print(f"Wrote {base_path}")
|
||||
print(f"Wrote {action_path}")
|
||||
print(f"Wrote {DOCS_DIR / 'liu-bei-handpaint-sample-contact.png'}")
|
||||
print(f"Wrote {DOCS_DIR / 'liu-bei-handpaint-sample-before-after.png'}")
|
||||
print(f"Wrote {DOCS_DIR / 'liu-bei-handpaint-sample-animation.gif'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 6.6 MiB |
|
Before Width: | Height: | Size: 607 KiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 7.1 MiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 6.7 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 6.2 MiB |
|
Before Width: | Height: | Size: 598 KiB After Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 6.1 MiB |
|
Before Width: | Height: | Size: 587 KiB After Width: | Height: | Size: 2.9 MiB |
@@ -7,7 +7,6 @@ const unitWalkFrameCount = 8;
|
||||
export const unitBaseFramesPerDirection = unitIdleFrameCount + unitWalkFrameCount;
|
||||
export const unitSheetFrameSize = 313;
|
||||
const unitWalkFrameRate = 10;
|
||||
const unitIdleFrameRate = 5;
|
||||
const unitSheetRows: Record<UnitDirection, number> = {
|
||||
south: 0,
|
||||
east: 1,
|
||||
@@ -99,10 +98,12 @@ export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable<string>
|
||||
key,
|
||||
frame: unitSheetRows[direction] * unitBaseFramesPerDirection + unitIdleFrameCount + frameIndex
|
||||
}));
|
||||
const idleFrames = Array.from({ length: unitIdleFrameCount }, (_, frameIndex) => ({
|
||||
key,
|
||||
frame: unitSheetRows[direction] * unitBaseFramesPerDirection + frameIndex
|
||||
}));
|
||||
const idleFrames = [
|
||||
{
|
||||
key,
|
||||
frame: unitSheetRows[direction] * unitBaseFramesPerDirection
|
||||
}
|
||||
];
|
||||
const walkAnimationKey = `${key}-walk-${direction}`;
|
||||
const idleAnimationKey = `${key}-idle-${direction}`;
|
||||
|
||||
@@ -119,7 +120,7 @@ export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable<string>
|
||||
scene.anims.create({
|
||||
key: idleAnimationKey,
|
||||
frames: idleFrames,
|
||||
frameRate: unitIdleFrameRate,
|
||||
frameRate: 1,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
|
||||