diff --git a/docs/battle-ui-icons-combat-cut-in-v1.png b/docs/battle-ui-icons-combat-cut-in-v1.png new file mode 100644 index 0000000..460231b Binary files /dev/null and b/docs/battle-ui-icons-combat-cut-in-v1.png differ diff --git a/docs/battle-ui-icons-combat-preview-v2.png b/docs/battle-ui-icons-combat-preview-v2.png new file mode 100644 index 0000000..39b6e07 Binary files /dev/null and b/docs/battle-ui-icons-combat-preview-v2.png differ diff --git a/docs/battle-ui-icons-unit-detail-v1.png b/docs/battle-ui-icons-unit-detail-v1.png new file mode 100644 index 0000000..f710471 Binary files /dev/null and b/docs/battle-ui-icons-unit-detail-v1.png differ diff --git a/docs/battle-ui-icons-unit-detail-v2.png b/docs/battle-ui-icons-unit-detail-v2.png new file mode 100644 index 0000000..8a96574 Binary files /dev/null and b/docs/battle-ui-icons-unit-detail-v2.png differ diff --git a/docs/handpaint-batch1-alpha-report.md b/docs/handpaint-batch1-alpha-report.md new file mode 100644 index 0000000..076a36a --- /dev/null +++ b/docs/handpaint-batch1-alpha-report.md @@ -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` diff --git a/docs/handpaint-batch1-battle-guan-yu.png b/docs/handpaint-batch1-battle-guan-yu.png new file mode 100644 index 0000000..8f4ce6d Binary files /dev/null and b/docs/handpaint-batch1-battle-guan-yu.png differ diff --git a/docs/handpaint-batch1-battle-overview.png b/docs/handpaint-batch1-battle-overview.png new file mode 100644 index 0000000..755ea47 Binary files /dev/null and b/docs/handpaint-batch1-battle-overview.png differ diff --git a/docs/handpaint-batch1-battle-rebel-archer.png b/docs/handpaint-batch1-battle-rebel-archer.png new file mode 100644 index 0000000..4d4eaaf Binary files /dev/null and b/docs/handpaint-batch1-battle-rebel-archer.png differ diff --git a/docs/handpaint-batch1-battle-rebel-cavalry.png b/docs/handpaint-batch1-battle-rebel-cavalry.png new file mode 100644 index 0000000..8d1a3ab Binary files /dev/null and b/docs/handpaint-batch1-battle-rebel-cavalry.png differ diff --git a/docs/handpaint-batch1-battle-rebel.png b/docs/handpaint-batch1-battle-rebel.png new file mode 100644 index 0000000..ad96c05 Binary files /dev/null and b/docs/handpaint-batch1-battle-rebel.png differ diff --git a/docs/handpaint-batch1-battle-zhang-fei.png b/docs/handpaint-batch1-battle-zhang-fei.png new file mode 100644 index 0000000..7c40f5f Binary files /dev/null and b/docs/handpaint-batch1-battle-zhang-fei.png differ diff --git a/docs/handpaint-batch1-idle-breathing-01.png b/docs/handpaint-batch1-idle-breathing-01.png new file mode 100644 index 0000000..247a553 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-01.png differ diff --git a/docs/handpaint-batch1-idle-breathing-02.png b/docs/handpaint-batch1-idle-breathing-02.png new file mode 100644 index 0000000..ae6066d Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-02.png differ diff --git a/docs/handpaint-batch1-idle-breathing-03.png b/docs/handpaint-batch1-idle-breathing-03.png new file mode 100644 index 0000000..162f3e2 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-03.png differ diff --git a/docs/handpaint-batch1-idle-breathing-04.png b/docs/handpaint-batch1-idle-breathing-04.png new file mode 100644 index 0000000..cf7d14a Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-04.png differ diff --git a/docs/handpaint-batch1-idle-breathing-05.png b/docs/handpaint-batch1-idle-breathing-05.png new file mode 100644 index 0000000..6274c8a Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-05.png differ diff --git a/docs/handpaint-batch1-idle-breathing-browser.gif b/docs/handpaint-batch1-idle-breathing-browser.gif new file mode 100644 index 0000000..c13170b Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-browser.gif differ diff --git a/docs/handpaint-batch1-idle-breathing-strip.png b/docs/handpaint-batch1-idle-breathing-strip.png new file mode 100644 index 0000000..d2042dd Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-strip.png differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-01.png b/docs/handpaint-batch1-idle-breathing-visible-01.png new file mode 100644 index 0000000..755ea47 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-01.png differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-02.png b/docs/handpaint-batch1-idle-breathing-visible-02.png new file mode 100644 index 0000000..d1fa9d3 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-02.png differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-03.png b/docs/handpaint-batch1-idle-breathing-visible-03.png new file mode 100644 index 0000000..95fa967 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-03.png differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-04.png b/docs/handpaint-batch1-idle-breathing-visible-04.png new file mode 100644 index 0000000..78eafc2 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-04.png differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-05.png b/docs/handpaint-batch1-idle-breathing-visible-05.png new file mode 100644 index 0000000..fb12a25 Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-05.png differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-browser.gif b/docs/handpaint-batch1-idle-breathing-visible-browser.gif new file mode 100644 index 0000000..50a82bb Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-browser.gif differ diff --git a/docs/handpaint-batch1-idle-breathing-visible-strip.png b/docs/handpaint-batch1-idle-breathing-visible-strip.png new file mode 100644 index 0000000..dcd213d Binary files /dev/null and b/docs/handpaint-batch1-idle-breathing-visible-strip.png differ diff --git a/docs/handpaint-batch1-idle-stability-01.png b/docs/handpaint-batch1-idle-stability-01.png new file mode 100644 index 0000000..01973b3 Binary files /dev/null and b/docs/handpaint-batch1-idle-stability-01.png differ diff --git a/docs/handpaint-batch1-idle-stability-02.png b/docs/handpaint-batch1-idle-stability-02.png new file mode 100644 index 0000000..b324a57 Binary files /dev/null and b/docs/handpaint-batch1-idle-stability-02.png differ diff --git a/docs/handpaint-batch1-idle-stability-03.png b/docs/handpaint-batch1-idle-stability-03.png new file mode 100644 index 0000000..93357d6 Binary files /dev/null and b/docs/handpaint-batch1-idle-stability-03.png differ diff --git a/docs/handpaint-batch1-idle-stability-browser.gif b/docs/handpaint-batch1-idle-stability-browser.gif new file mode 100644 index 0000000..822d9a6 Binary files /dev/null and b/docs/handpaint-batch1-idle-stability-browser.gif differ diff --git a/docs/handpaint-batch1-idle-stability-strip.png b/docs/handpaint-batch1-idle-stability-strip.png new file mode 100644 index 0000000..8c41b87 Binary files /dev/null and b/docs/handpaint-batch1-idle-stability-strip.png differ diff --git a/docs/handpaint-batch1-liu-guan-size-match-battle.png b/docs/handpaint-batch1-liu-guan-size-match-battle.png new file mode 100644 index 0000000..8ebcfbe Binary files /dev/null and b/docs/handpaint-batch1-liu-guan-size-match-battle.png differ diff --git a/docs/handpaint-batch1-overview-contact.png b/docs/handpaint-batch1-overview-contact.png new file mode 100644 index 0000000..f08dedb Binary files /dev/null and b/docs/handpaint-batch1-overview-contact.png differ diff --git a/docs/handpaint-batch1-public-deploy-battle.png b/docs/handpaint-batch1-public-deploy-battle.png new file mode 100644 index 0000000..3e7e14c Binary files /dev/null and b/docs/handpaint-batch1-public-deploy-battle.png differ diff --git a/docs/handpaint-batch1-public-deploy-title.png b/docs/handpaint-batch1-public-deploy-title.png new file mode 100644 index 0000000..3c7ba76 Binary files /dev/null and b/docs/handpaint-batch1-public-deploy-title.png differ diff --git a/docs/handpaint-batch1-unit-guan-yu-animation.gif b/docs/handpaint-batch1-unit-guan-yu-animation.gif new file mode 100644 index 0000000..1f64558 Binary files /dev/null and b/docs/handpaint-batch1-unit-guan-yu-animation.gif differ diff --git a/docs/handpaint-batch1-unit-guan-yu-before-after.png b/docs/handpaint-batch1-unit-guan-yu-before-after.png new file mode 100644 index 0000000..d772c8d Binary files /dev/null and b/docs/handpaint-batch1-unit-guan-yu-before-after.png differ diff --git a/docs/handpaint-batch1-unit-guan-yu-contact.png b/docs/handpaint-batch1-unit-guan-yu-contact.png new file mode 100644 index 0000000..145ad64 Binary files /dev/null and b/docs/handpaint-batch1-unit-guan-yu-contact.png differ diff --git a/docs/handpaint-batch1-unit-rebel-animation.gif b/docs/handpaint-batch1-unit-rebel-animation.gif new file mode 100644 index 0000000..8e55ee5 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-animation.gif differ diff --git a/docs/handpaint-batch1-unit-rebel-archer-animation.gif b/docs/handpaint-batch1-unit-rebel-archer-animation.gif new file mode 100644 index 0000000..5a21ce8 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-archer-animation.gif differ diff --git a/docs/handpaint-batch1-unit-rebel-archer-before-after.png b/docs/handpaint-batch1-unit-rebel-archer-before-after.png new file mode 100644 index 0000000..96d7669 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-archer-before-after.png differ diff --git a/docs/handpaint-batch1-unit-rebel-archer-contact.png b/docs/handpaint-batch1-unit-rebel-archer-contact.png new file mode 100644 index 0000000..9d8ac83 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-archer-contact.png differ diff --git a/docs/handpaint-batch1-unit-rebel-before-after.png b/docs/handpaint-batch1-unit-rebel-before-after.png new file mode 100644 index 0000000..846dd76 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-before-after.png differ diff --git a/docs/handpaint-batch1-unit-rebel-cavalry-animation.gif b/docs/handpaint-batch1-unit-rebel-cavalry-animation.gif new file mode 100644 index 0000000..4f0ea02 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-cavalry-animation.gif differ diff --git a/docs/handpaint-batch1-unit-rebel-cavalry-before-after.png b/docs/handpaint-batch1-unit-rebel-cavalry-before-after.png new file mode 100644 index 0000000..6e401b0 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-cavalry-before-after.png differ diff --git a/docs/handpaint-batch1-unit-rebel-cavalry-contact.png b/docs/handpaint-batch1-unit-rebel-cavalry-contact.png new file mode 100644 index 0000000..0bc2391 Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-cavalry-contact.png differ diff --git a/docs/handpaint-batch1-unit-rebel-contact.png b/docs/handpaint-batch1-unit-rebel-contact.png new file mode 100644 index 0000000..1f1040a Binary files /dev/null and b/docs/handpaint-batch1-unit-rebel-contact.png differ diff --git a/docs/handpaint-batch1-unit-zhang-fei-animation.gif b/docs/handpaint-batch1-unit-zhang-fei-animation.gif new file mode 100644 index 0000000..4fd4d44 Binary files /dev/null and b/docs/handpaint-batch1-unit-zhang-fei-animation.gif differ diff --git a/docs/handpaint-batch1-unit-zhang-fei-before-after.png b/docs/handpaint-batch1-unit-zhang-fei-before-after.png new file mode 100644 index 0000000..c75f283 Binary files /dev/null and b/docs/handpaint-batch1-unit-zhang-fei-before-after.png differ diff --git a/docs/handpaint-batch1-unit-zhang-fei-contact.png b/docs/handpaint-batch1-unit-zhang-fei-contact.png new file mode 100644 index 0000000..4a5719f Binary files /dev/null and b/docs/handpaint-batch1-unit-zhang-fei-contact.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-01.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-01.png new file mode 100644 index 0000000..94c3801 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-01.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-02.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-02.png new file mode 100644 index 0000000..3bdf764 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-02.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-03.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-03.png new file mode 100644 index 0000000..2712de6 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-03.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-04.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-04.png new file mode 100644 index 0000000..a465120 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-04.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-05.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-05.png new file mode 100644 index 0000000..4a0870a Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-05.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-06.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-06.png new file mode 100644 index 0000000..7891b8e Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-06.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-07.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-07.png new file mode 100644 index 0000000..3224ad3 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-07.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-after.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-after.png new file mode 100644 index 0000000..6032094 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-after.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-01.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-01.png new file mode 100644 index 0000000..a136296 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-01.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-02.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-02.png new file mode 100644 index 0000000..e28e89d Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-02.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-03.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-03.png new file mode 100644 index 0000000..6135906 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-03.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-04.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-04.png new file mode 100644 index 0000000..4cfa51e Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-04.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-05.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-05.png new file mode 100644 index 0000000..5e4ec30 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-05.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-06.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-06.png new file mode 100644 index 0000000..3372316 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-06.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-07.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-07.png new file mode 100644 index 0000000..5324957 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-07.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-08.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-08.png new file mode 100644 index 0000000..e6521ec Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-08.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-after.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-after.png new file mode 100644 index 0000000..712d25d Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-large-after.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-220-selected.png b/docs/handpaint-batch1-zhang-fei-walk-fit-220-selected.png new file mode 100644 index 0000000..9195061 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-220-selected.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-moved.png b/docs/handpaint-batch1-zhang-fei-walk-fit-moved.png new file mode 100644 index 0000000..b994ef7 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-moved.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-moving.png b/docs/handpaint-batch1-zhang-fei-walk-fit-moving.png new file mode 100644 index 0000000..5b777bd Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-moving.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-fit-selected.png b/docs/handpaint-batch1-zhang-fei-walk-fit-selected.png new file mode 100644 index 0000000..cfab756 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-fit-selected.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-01.png b/docs/handpaint-batch1-zhang-fei-walk-long-01.png new file mode 100644 index 0000000..2536755 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-01.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-02.png b/docs/handpaint-batch1-zhang-fei-walk-long-02.png new file mode 100644 index 0000000..76bbdd3 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-02.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-03.png b/docs/handpaint-batch1-zhang-fei-walk-long-03.png new file mode 100644 index 0000000..1a2f683 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-03.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-04.png b/docs/handpaint-batch1-zhang-fei-walk-long-04.png new file mode 100644 index 0000000..eae4b11 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-04.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-05.png b/docs/handpaint-batch1-zhang-fei-walk-long-05.png new file mode 100644 index 0000000..4d7e0d3 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-05.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-06.png b/docs/handpaint-batch1-zhang-fei-walk-long-06.png new file mode 100644 index 0000000..b824d4d Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-06.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-07.png b/docs/handpaint-batch1-zhang-fei-walk-long-07.png new file mode 100644 index 0000000..3fce636 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-07.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-after.png b/docs/handpaint-batch1-zhang-fei-walk-long-after.png new file mode 100644 index 0000000..810402b Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-after.png differ diff --git a/docs/handpaint-batch1-zhang-fei-walk-long-selected.png b/docs/handpaint-batch1-zhang-fei-walk-long-selected.png new file mode 100644 index 0000000..b47f5d4 Binary files /dev/null and b/docs/handpaint-batch1-zhang-fei-walk-long-selected.png differ diff --git a/docs/liu-bei-handpaint-sample-animation.gif b/docs/liu-bei-handpaint-sample-animation.gif new file mode 100644 index 0000000..2dc50ed Binary files /dev/null and b/docs/liu-bei-handpaint-sample-animation.gif differ diff --git a/docs/liu-bei-handpaint-sample-battle.png b/docs/liu-bei-handpaint-sample-battle.png new file mode 100644 index 0000000..26116c8 Binary files /dev/null and b/docs/liu-bei-handpaint-sample-battle.png differ diff --git a/docs/liu-bei-handpaint-sample-before-after.png b/docs/liu-bei-handpaint-sample-before-after.png new file mode 100644 index 0000000..a2cd857 Binary files /dev/null and b/docs/liu-bei-handpaint-sample-before-after.png differ diff --git a/docs/liu-bei-handpaint-sample-contact.png b/docs/liu-bei-handpaint-sample-contact.png new file mode 100644 index 0000000..9888427 Binary files /dev/null and b/docs/liu-bei-handpaint-sample-contact.png differ diff --git a/docs/liu-bei-handpaint-sample-detail.png b/docs/liu-bei-handpaint-sample-detail.png new file mode 100644 index 0000000..e9c6234 Binary files /dev/null and b/docs/liu-bei-handpaint-sample-detail.png differ diff --git a/docs/tactical-unit-sprite-art-direction.md b/docs/tactical-unit-sprite-art-direction.md new file mode 100644 index 0000000..c9ad817 --- /dev/null +++ b/docs/tactical-unit-sprite-art-direction.md @@ -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. diff --git a/scripts/assemble-handpaint-batch1-sprites.py b/scripts/assemble-handpaint-batch1-sprites.py new file mode 100644 index 0000000..f21e28f --- /dev/null +++ b/scripts/assemble-handpaint-batch1-sprites.py @@ -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() diff --git a/scripts/assemble-liu-bei-handpaint-sample.py b/scripts/assemble-liu-bei-handpaint-sample.py new file mode 100644 index 0000000..3fd4bc2 --- /dev/null +++ b/scripts/assemble-liu-bei-handpaint-sample.py @@ -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() diff --git a/src/assets/images/units/unit-guan-yu-actions.png b/src/assets/images/units/unit-guan-yu-actions.png index 496ceb0..0fbc4c2 100644 Binary files a/src/assets/images/units/unit-guan-yu-actions.png and b/src/assets/images/units/unit-guan-yu-actions.png differ diff --git a/src/assets/images/units/unit-guan-yu.png b/src/assets/images/units/unit-guan-yu.png index f8565e1..fb69725 100644 Binary files a/src/assets/images/units/unit-guan-yu.png and b/src/assets/images/units/unit-guan-yu.png differ diff --git a/src/assets/images/units/unit-liu-bei-actions.png b/src/assets/images/units/unit-liu-bei-actions.png index 29b8dd1..a592d7c 100644 Binary files a/src/assets/images/units/unit-liu-bei-actions.png and b/src/assets/images/units/unit-liu-bei-actions.png differ diff --git a/src/assets/images/units/unit-liu-bei.png b/src/assets/images/units/unit-liu-bei.png index 81b14f8..290e898 100644 Binary files a/src/assets/images/units/unit-liu-bei.png and b/src/assets/images/units/unit-liu-bei.png differ diff --git a/src/assets/images/units/unit-rebel-actions.png b/src/assets/images/units/unit-rebel-actions.png index 0f32c0d..753b076 100644 Binary files a/src/assets/images/units/unit-rebel-actions.png and b/src/assets/images/units/unit-rebel-actions.png differ diff --git a/src/assets/images/units/unit-rebel-archer-actions.png b/src/assets/images/units/unit-rebel-archer-actions.png index cc80c7b..eb6fc7d 100644 Binary files a/src/assets/images/units/unit-rebel-archer-actions.png and b/src/assets/images/units/unit-rebel-archer-actions.png differ diff --git a/src/assets/images/units/unit-rebel-archer.png b/src/assets/images/units/unit-rebel-archer.png index 12db59d..744d0d2 100644 Binary files a/src/assets/images/units/unit-rebel-archer.png and b/src/assets/images/units/unit-rebel-archer.png differ diff --git a/src/assets/images/units/unit-rebel-cavalry-actions.png b/src/assets/images/units/unit-rebel-cavalry-actions.png index f43b0b6..5554e67 100644 Binary files a/src/assets/images/units/unit-rebel-cavalry-actions.png and b/src/assets/images/units/unit-rebel-cavalry-actions.png differ diff --git a/src/assets/images/units/unit-rebel-cavalry.png b/src/assets/images/units/unit-rebel-cavalry.png index 9b33340..085f0b6 100644 Binary files a/src/assets/images/units/unit-rebel-cavalry.png and b/src/assets/images/units/unit-rebel-cavalry.png differ diff --git a/src/assets/images/units/unit-rebel.png b/src/assets/images/units/unit-rebel.png index 7eef305..32847a2 100644 Binary files a/src/assets/images/units/unit-rebel.png and b/src/assets/images/units/unit-rebel.png differ diff --git a/src/assets/images/units/unit-zhang-fei-actions.png b/src/assets/images/units/unit-zhang-fei-actions.png index 48f3bfc..d24a58c 100644 Binary files a/src/assets/images/units/unit-zhang-fei-actions.png and b/src/assets/images/units/unit-zhang-fei-actions.png differ diff --git a/src/assets/images/units/unit-zhang-fei.png b/src/assets/images/units/unit-zhang-fei.png index 0231437..f27ba9c 100644 Binary files a/src/assets/images/units/unit-zhang-fei.png and b/src/assets/images/units/unit-zhang-fei.png differ diff --git a/src/game/data/unitAssets.ts b/src/game/data/unitAssets.ts index e699d6b..a74352b 100644 --- a/src/game/data/unitAssets.ts +++ b/src/game/data/unitAssets.ts @@ -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 = { south: 0, east: 1, @@ -99,10 +98,12 @@ export function ensureUnitAnimations(scene: Phaser.Scene, keys: Iterable 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 scene.anims.create({ key: idleAnimationKey, frames: idleFrames, - frameRate: unitIdleFrameRate, + frameRate: 1, repeat: -1 }); } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index ce09544..532001d 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -3200,7 +3200,7 @@ export class BattleScene extends Phaser.Scene { private drawUnits() { battleUnits.forEach((unit) => { - const sizeRatio = unit.faction === 'ally' ? 1.32 : 1.24; + const sizeRatio = this.unitDisplaySizeRatio(unit); const textureBase = this.unitTextureKey(unit); const centerX = this.tileCenterX(unit.x); const centerY = this.tileCenterY(unit.y); @@ -3243,6 +3243,11 @@ export class BattleScene extends Phaser.Scene { }); } + private unitDisplaySizeRatio(unit: UnitData) { + const baseRatio = unit.faction === 'ally' ? 1.32 : 1.24; + return unit.id === 'guan-yu' ? baseRatio * 1.03 : baseRatio; + } + private drawSidePanel() { const { panelX, panelY, panelWidth, panelHeight } = this.layout; this.add.rectangle(panelX, panelY, panelWidth, panelHeight, palette.panel, 0.96).setOrigin(0); @@ -9670,19 +9675,12 @@ export class BattleScene extends Phaser.Scene { private startUnitIdleMotion(unit: UnitData, view: UnitView, direction: UnitDirection) { const mounted = this.isMountedUnit(unit); const seed = this.hashString(unit.id) % 160; - const sway = direction === 'west' ? -1 : 1; - const bob = mounted ? 2.1 : 1.35; - const duration = mounted ? 520 + seed * 0.45 : 880 + seed * 0.8; - const angle = mounted ? 0.45 * sway : 0.65 * sway; - const scaleX = view.baseScaleX * (mounted ? 1.012 : 0.992); - const scaleY = view.baseScaleY * (mounted ? 0.988 : 1.014); + const bob = mounted ? 2.35 : 1.9; + const duration = mounted ? 660 + seed * 0.32 : 900 + seed * 0.5; view.idleTween = this.tweens.add({ targets: view.sprite, y: view.baseY - bob, - angle, - scaleX, - scaleY, duration, ease: 'Sine.easeInOut', yoyo: true,