Approve Shu infantry handpainted sprite v8
BIN
docs/handpaint-batch2-shu-infantry-quality-restart-source-v8.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 887 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 140 KiB |
@@ -0,0 +1,24 @@
|
||||
# Shu Infantry Quality Restart Sample v8
|
||||
|
||||
- source: `handpaint-batch2-shu-infantry-quality-restart-source-v8.png`
|
||||
- base status: ok
|
||||
- action status: ok
|
||||
- base partial alpha: 0, opaque pixels: 2034444
|
||||
- action partial alpha: 0, opaque pixels: 4825025
|
||||
- base frame border hits: 0
|
||||
- action frame border hits: 0
|
||||
- base empty frames: 0
|
||||
- action empty frames: 0
|
||||
- base bbox width range: 183..222
|
||||
- base bbox height range: 288..288
|
||||
- base bottom range: 303..303
|
||||
- action bbox width range: 181..255
|
||||
- action bbox height range: 280..288
|
||||
- action bottom range: 303..303
|
||||
- contact: `handpaint-batch2-shu-infantry-quality-restart-v8-contact.png`
|
||||
- animation: `handpaint-batch2-shu-infantry-quality-restart-v8-animation.gif`
|
||||
- before/after: `handpaint-batch2-shu-infantry-quality-restart-v8-before-after.png`
|
||||
- scale compare: `handpaint-batch2-shu-infantry-quality-restart-v8-scale-compare.png`
|
||||
- battle composite: `handpaint-batch2-shu-infantry-quality-restart-v8-battle-composite.png`
|
||||
- manual checks: run build and local browser verification after generation
|
||||
- note: battle composite is a scale review image; gameplay roster is unchanged during sample approval
|
||||
|
After Width: | Height: | Size: 50 KiB |
30
docs/unit-sprite-art-quality-gate.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Unit Sprite Art Quality Gate
|
||||
|
||||
This file is generated by `scripts/check-unit-sprite-art-quality.py`.
|
||||
|
||||
This gate catches obvious sprite-art failures before user approval. It does not replace manual visual approval.
|
||||
|
||||
- Manifest: `docs/unit-sprite-rework-manifest.json`
|
||||
- Audit: `docs/unit-sprite-rework-audit.json`
|
||||
- Approved baseline units: 7
|
||||
- Pending approval units: 0
|
||||
- Queued rework units: 84
|
||||
|
||||
## Checked Rules
|
||||
|
||||
- Required approval artifacts exist, including contact sheet, GIF, scale comparison, battle preview, and current approval board.
|
||||
- Base/action sheets have no partial alpha, border hits, or empty frames.
|
||||
- 50px battle-scale silhouette is not too small or too tall.
|
||||
- Idle/walk base frames stay vertically stable.
|
||||
- Approval packet points at the current approval board and browser debug preview evidence.
|
||||
|
||||
## none
|
||||
|
||||
Status: OK
|
||||
|
||||
Evidence:
|
||||
- no pending approval units: nothing to check
|
||||
|
||||
## Result
|
||||
|
||||
OK
|
||||
90
docs/unit-sprite-batch-output-checklist.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Unit Sprite Batch Output Checklist
|
||||
|
||||
Use this checklist for every unit and every batch before approval, commit, push, or deploy.
|
||||
|
||||
Pipeline reference: `unit-sprite-handpaint-pipeline-guide.md`.
|
||||
|
||||
Review state manifest: `unit-sprite-rework-manifest.json`.
|
||||
|
||||
## Per-Unit Required Artifacts
|
||||
|
||||
- Production spec for the unit, when one exists in `docs/unit-sprite-batch*-*-spec.md`.
|
||||
- Source image on flat chroma-key background.
|
||||
- Base sheet in `src/assets/images/units/<unit-key>.png`.
|
||||
- Action sheet in `src/assets/images/units/<unit-key>-actions.png`.
|
||||
- Contact sheet in `docs/`.
|
||||
- Idle/walk/action GIF in `docs/`.
|
||||
- Previous/new comparison image in `docs/`.
|
||||
- 50px/72px scale comparison image in `docs/`.
|
||||
- Battle map composite in `docs/`.
|
||||
- Debug sprite preview battle screenshot in `docs/` for the current pending sample.
|
||||
- Per-unit report in `docs/`.
|
||||
|
||||
## Per-Unit Technical Checks
|
||||
|
||||
- Base sheet size is exactly `5008x1252`.
|
||||
- Action sheet size is exactly `11268x1252`.
|
||||
- Frame size remains `313x313`.
|
||||
- Base partial alpha is `0`.
|
||||
- Action partial alpha is `0`.
|
||||
- No idle/walk frame touches the frame border.
|
||||
- No detached fragments in idle/walk frames.
|
||||
- Body scale and foot line are stable during idle/walk.
|
||||
- Action frames may have larger silhouettes, but must not visibly clip in game scale.
|
||||
- Unit appears as `ok` in `docs/unit-sprite-rework-audit.json`.
|
||||
- Unit review stage is correct:
|
||||
- approved units use `approved-baseline`
|
||||
- current sample uses `pending-approval`
|
||||
- untouched future units remain `queued-rework`
|
||||
|
||||
## Per-Unit Visual Checks
|
||||
|
||||
- The unit reads clearly at 50px.
|
||||
- The 50px/72px comparison is checked against approved units, not only against the old placeholder.
|
||||
- Weapon/class reads from silhouette:
|
||||
- sword/infantry
|
||||
- spear
|
||||
- bow
|
||||
- horse/cavalry
|
||||
- fan/scroll/strategist
|
||||
- officer/banner
|
||||
- Faction palette reads clearly.
|
||||
- The sprite itself solves readability; no rings, base plates, badges, glows, or UI decorations are used to compensate.
|
||||
- Style remains aligned with approved hand-painted batch 1 and the current `unit-shu-infantry` approval candidate.
|
||||
|
||||
## Per-Batch Checks
|
||||
|
||||
- `python scripts/audit-unit-sprite-sheets.py`
|
||||
- `python scripts/render-unit-sprite-review-boards.py`
|
||||
- `python scripts/check-unit-sprite-art-quality.py`
|
||||
- `python scripts/check-unit-sprite-approval-gate.py --mode sample`
|
||||
- `python scripts/write-unit-sprite-next-steps.py`
|
||||
- `python scripts/check-unit-sprite-workflow-consistency.py`
|
||||
- Review the relevant `docs/unit-sprite-review-<batch>.png` by eye.
|
||||
- `pnpm build`
|
||||
- Local PC browser check.
|
||||
- For pending samples, local PC browser check with `?debug&debugSpritePreview=<unit-key>`.
|
||||
- Console errors: `0`.
|
||||
- Approval received for the batch before commit/push/deploy.
|
||||
|
||||
## Commit/Deploy Gate
|
||||
|
||||
Do not commit, push, or deploy when:
|
||||
|
||||
- The current sample is still awaiting approval.
|
||||
- Any unit in the batch has failed the technical checks.
|
||||
- Any newly edited unit is still marked `pending-approval`.
|
||||
- Any untouched unit is accidentally counted as approved because it is only technically `ok`.
|
||||
- The review board shows the new unit is not visibly better than the old placeholder.
|
||||
- The browser check has not been run.
|
||||
- Console errors are present.
|
||||
|
||||
After approval and successful verification:
|
||||
|
||||
- Run `python scripts\promote-unit-sprite-approval.py --unit <unit-key> --expected-version <version>` so approved units move from `pendingApproval` to `approvedBaseline`.
|
||||
- Regenerate audit and review boards after the manifest update.
|
||||
- Run `python scripts/check-unit-sprite-approval-gate.py --mode release`.
|
||||
- Commit the batch changes.
|
||||
- Push to the Gitea remote.
|
||||
- Deploy to the NAS-hosted URL.
|
||||
- Provide the deployed URL and representative review images.
|
||||
1385
docs/unit-sprite-batch-queue.json
Normal file
192
docs/unit-sprite-batch-queue.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Unit Sprite Batch Queue
|
||||
|
||||
This file is generated by `scripts/write-unit-sprite-batch-queue.py`.
|
||||
|
||||
Use this as the long-running queue for the full hand-painted sprite rework. It is inventory and sequencing, not approval by itself.
|
||||
|
||||
## Current State
|
||||
|
||||
- Approved baseline units: 6
|
||||
- Pending approval units: 1
|
||||
- Queued rework units: 84
|
||||
- Manifest: `docs/unit-sprite-rework-manifest.json`
|
||||
- Audit: `docs/unit-sprite-rework-audit.json`
|
||||
|
||||
## Approval Blocker
|
||||
|
||||
Do not start the next unit, commit, push, or deploy while these units are pending:
|
||||
|
||||
- `unit-shu-infantry`
|
||||
|
||||
## Next After Approval
|
||||
|
||||
1. `unit-shu-spearman` - spec `unit-sprite-batch2-shu-spearman-spec.md`
|
||||
2. `unit-shu-archer` - spec `unit-sprite-batch2-shu-archer-spec.md`
|
||||
3. `unit-shu-cavalry` - spec `unit-sprite-batch2-shu-cavalry-spec.md`
|
||||
4. `unit-shu-strategist` - spec `unit-sprite-batch2-shu-strategist-spec.md`
|
||||
5. `unit-shu-officer` - spec `unit-sprite-batch2-shu-officer-spec.md`
|
||||
|
||||
## Batch Queue
|
||||
|
||||
### Batch 1 approved core heroes
|
||||
|
||||
- Batch key: `batch1-approved-core`
|
||||
- Total units: 3
|
||||
- Stage counts: approved-baseline 3
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-guan-yu` | approved reference | approved-baseline | technical ok | 30.4..43.1 x 44.1..46.5 | |
|
||||
| `unit-liu-bei` | approved reference | approved-baseline | technical ok | 22.0..41.2 x 44.4..46.2 | |
|
||||
| `unit-zhang-fei` | approved reference | approved-baseline | technical ok | 22.8..48.9 x 43.8..46.2 | |
|
||||
|
||||
### Batch 1 approved rebel references
|
||||
|
||||
- Batch key: `batch1-approved-rebel`
|
||||
- Total units: 3
|
||||
- Stage counts: approved-baseline 3
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-rebel` | approved reference | approved-baseline | technical ok | 31.2..45.4 x 43.3..44.9 | |
|
||||
| `unit-rebel-archer` | approved reference | approved-baseline | technical ok | 29.6..42.0 x 43.5..44.4 | |
|
||||
| `unit-rebel-cavalry` | approved reference | approved-baseline | technical ok | 22.2..46.2 x 42.5..44.9 | |
|
||||
|
||||
### Batch 2 Shu generic units
|
||||
|
||||
- Batch key: `batch2-shu`
|
||||
- Total units: 18
|
||||
- Stage counts: pending-approval 1, queued-rework 17
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-shu-infantry` | current approval sample | pending-approval | technical ok | 25.6..32.9 x 46.3..47.3 | |
|
||||
| `unit-shu-spearman` | next primary queue | queued-rework | base border-hits=32; actions border-hits=82 | 31.0..40.6 x 44.6..44.6 | `unit-sprite-batch2-shu-spearman-spec.md` |
|
||||
| `unit-shu-archer` | next primary queue | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | `unit-sprite-batch2-shu-archer-spec.md` |
|
||||
| `unit-shu-cavalry` | next primary queue | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | `unit-sprite-batch2-shu-cavalry-spec.md` |
|
||||
| `unit-shu-strategist` | next primary queue | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | `unit-sprite-batch2-shu-strategist-spec.md` |
|
||||
| `unit-shu-officer` | next primary queue | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | `unit-sprite-batch2-shu-officer-spec.md` |
|
||||
| `unit-shu-archer-longbow` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-shu-archer-veteran` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 39.8..39.9 x 45.0..46.0 | |
|
||||
| `unit-shu-cavalry-scout` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | |
|
||||
| `unit-shu-cavalry-white` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | |
|
||||
| `unit-shu-infantry-banner` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-shu-infantry-guard` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 31.6..44.7 x 45.0..46.0 | |
|
||||
| `unit-shu-officer-council` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-shu-officer-vanguard` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 33.2..41.2 x 45.0..46.0 | |
|
||||
| `unit-shu-spearman-guard` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 32.3..41.2 x 45.0..46.0 | |
|
||||
| `unit-shu-spearman-veteran` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 32.3..41.2 x 45.0..46.0 | |
|
||||
| `unit-shu-strategist-field` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-shu-strategist-white` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
|
||||
### Batch 3 Wei generic units
|
||||
|
||||
- Batch key: `batch3-wei`
|
||||
- Total units: 16
|
||||
- Stage counts: queued-rework 16
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-wei-archer` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-wei-archer-crossbow` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-wei-archer-veteran` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 39.8..39.9 x 45.0..46.0 | |
|
||||
| `unit-wei-cavalry` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | |
|
||||
| `unit-wei-cavalry-elite` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 26.5..47.3 x 45.0..46.0 | |
|
||||
| `unit-wei-cavalry-iron` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 26.5..47.3 x 45.0..46.0 | |
|
||||
| `unit-wei-infantry` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-wei-infantry-shield` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-wei-infantry-veteran` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 31.6..44.7 x 45.0..46.0 | |
|
||||
| `unit-wei-officer` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-wei-officer-gate` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-wei-officer-iron` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 33.2..41.2 x 45.0..46.0 | |
|
||||
| `unit-wei-strategist` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wei-strategist-black` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wei-strategist-blue` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wei-yan` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.0..40.6 x 44.6..44.6 | |
|
||||
|
||||
### Batch 4 Wu generic units
|
||||
|
||||
- Batch key: `batch4-wu`
|
||||
- Total units: 16
|
||||
- Stage counts: queued-rework 16
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-wu-archer` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-wu-archer-naval` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-wu-archer-river` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-wu-cavalry` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | |
|
||||
| `unit-wu-cavalry-elite` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 26.5..47.3 x 45.0..46.0 | |
|
||||
| `unit-wu-cavalry-river` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | |
|
||||
| `unit-wu-infantry` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-wu-infantry-marine` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-wu-infantry-river` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-wu-officer` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-wu-officer-harbor` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-wu-officer-river` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-wu-strategist` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wu-strategist-fire` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wu-strategist-river` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wu-yi` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
|
||||
### Batch 5 remaining rebel/bandit units
|
||||
|
||||
- Batch key: `batch5-rebel-other`
|
||||
- Total units: 6
|
||||
- Stage counts: queued-rework 6
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-rebel-archer-fire` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 42.5..42.5 | |
|
||||
| `unit-rebel-archer-veteran` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.8..39.9 x 44.1..44.2 | |
|
||||
| `unit-rebel-cavalry-veteran` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 26.5..47.3 x 45.0..46.0 | |
|
||||
| `unit-rebel-leader` | queued rework | queued-rework | base border-hits=32; actions border-hits=72 | 31.9..40.6 x 42.5..42.5 | |
|
||||
| `unit-rebel-leader-warlord` | queued rework | queued-rework | base border-hits=32; actions border-hits=78 | 35.8..42.5 x 44.1..44.2 | |
|
||||
| `unit-rebel-ragged` | queued rework | queued-rework | base border-hits=32; actions border-hits=72 | 30.7..44.2 x 42.5..42.5 | |
|
||||
|
||||
### Batch 6 Nanman/special units
|
||||
|
||||
- Batch key: `batch6-nanman-special`
|
||||
- Total units: 10
|
||||
- Stage counts: queued-rework 10
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-meng-huo` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 33.2..45.5 x 44.6..44.6 | |
|
||||
| `unit-nanman-infantry` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-nanman-infantry-jungle` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-nanman-infantry-spear` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-nanman-officer` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-nanman-officer-bronze` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 33.2..41.2 x 45.0..46.0 | |
|
||||
| `unit-nanman-officer-warlord` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 35.8..42.5 x 45.0..46.0 | |
|
||||
| `unit-nanman-shaman` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-nanman-shaman-ember` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-nanman-shaman-storm` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
|
||||
### Batch 7 unique officers
|
||||
|
||||
- Batch key: `batch7-unique-officer`
|
||||
- Total units: 19
|
||||
- Stage counts: queued-rework 19
|
||||
|
||||
| unit | role | stage | status | battle base px | spec |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `unit-cao-cao` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 33.2..43.3 x 45.0..46.0 | |
|
||||
| `unit-fa-zheng` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-gong-zhi` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-huang-quan` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-huang-zhong` | queued rework | queued-rework | base border-hits=64; actions border-hits=140 | 39.1..39.3 x 44.6..44.6 | |
|
||||
| `unit-jiang-wei` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.0..40.6 x 44.6..44.6 | |
|
||||
| `unit-li-yan` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-lu-bu` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 28.8..47.3 x 45.0..46.0 | |
|
||||
| `unit-lu-meng` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 30.7..44.2 x 44.6..44.6 | |
|
||||
| `unit-ma-chao` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 26.5..47.3 x 45.0..46.0 | |
|
||||
| `unit-ma-dai` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 25.6..46.5 x 45.0..46.2 | |
|
||||
| `unit-ma-liang` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-pang-tong` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-sima-yi` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-wang-ping` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.9..40.6 x 44.6..44.6 | |
|
||||
| `unit-yan-yan` | queued rework | queued-rework | base border-hits=32; actions border-hits=82 | 31.0..40.6 x 44.6..44.6 | |
|
||||
| `unit-yi-ji` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
| `unit-zhao-yun` | queued rework | queued-rework | base border-hits=64; actions border-hits=144 | 26.5..47.3 x 45.0..46.0 | |
|
||||
| `unit-zhuge-liang` | queued rework | queued-rework | base border-hits=32; actions border-hits=98 | 38.8..39.3 x 44.7..44.9 | |
|
||||
64
docs/unit-sprite-batch2-shu-archer-spec.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Batch 2 Shu Archer Production Spec
|
||||
|
||||
Use this only after `unit-shu-infantry` v3 is approved and the current queue reaches `unit-shu-archer`.
|
||||
|
||||
## Target
|
||||
|
||||
- Unit key: `unit-shu-archer`
|
||||
- Batch: Batch 2 Shu generic units
|
||||
- Role: generic Shu bow infantry
|
||||
- Style reference:
|
||||
- `unit-liu-bei`
|
||||
- `unit-guan-yu`
|
||||
- `unit-zhang-fei`
|
||||
- `unit-shu-infantry` v3
|
||||
- Required output sheets:
|
||||
- `src/assets/images/units/unit-shu-archer.png`
|
||||
- `src/assets/images/units/unit-shu-archer-actions.png`
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Must read as a bow unit at 50px battle scale.
|
||||
- Bow curve and arrow line must be visible in idle, walk, and attack poses.
|
||||
- Body should be leaner than infantry but not thin or low-contrast.
|
||||
- Use green Shu cloth, bronze leather armor, yellow-green sash, dark outline, and large readable color blocks.
|
||||
- Keep bow, quiver, and arrow contained inside each 313px frame.
|
||||
- No detached arrow fragments in idle/walk; action arrow effects must be reviewed and contained.
|
||||
- Do not solve readability with base rings, badges, glows, or UI decoration.
|
||||
|
||||
## Source Image Prompt
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production source image for a tactical RPG unit sprite sheet
|
||||
Primary request: Create a high-quality hand-painted raster contact sheet for ONE Shu archer unit, in the same production style as the approved Shu infantry v3 sample but clearly differentiated by a readable bow silhouette.
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background only, no shadows, no floor plane, no texture, no text.
|
||||
Subject: original Three Kingdoms-inspired Shu archer infantry, no copyrighted game assets, no logos. Green cloth coat, bronze leather armor plates, yellow-green sash, dark boots, small helmet or headwrap, quiver on back or hip, curved wooden bow and visible arrows. The character should look like an elite generic Shu field archer.
|
||||
Style/medium: premium hand-painted pixel-adjacent raster game art, bold dark ink outline, large readable color masses, crisp opaque body shapes, subtle painted fabric folds, tactical RPG battle sprite source art.
|
||||
Composition/framing: 6 columns by 2 rows contact sheet, twelve separate full-body frames, each frame centered with generous padding and the entire bow visible. No cropping. Keep each figure around 76-82% of its cell height with stable body scale and ground position across all frames.
|
||||
Frames left to right, top row: front idle holding bow vertically, front walk step, east walk side with bow carried forward, back walk with quiver visible, west walk side mirrored pose, attack windup drawing bow. Bottom row: arrow release with bow fully drawn, follow-through with bow lowered, command gesture, item/strategy gesture, hurt recoil, victory pose raising bow.
|
||||
Animation constraints: each frame should be newly drawn with natural pose differences in arms, legs, bow, quiver, sash, and cloth folds, but body size must remain stable. Idle should not twist sideways. Walk frames should not drift far horizontally. Action frames must stay inside their cell.
|
||||
Readability constraints: at 50px game scale the bow arc, arrow gesture, green Shu armor, and archer body block must read before the background. Use thicker silhouette, deeper shadows, stronger outline, fewer tiny details, clear weapon shape.
|
||||
Avoid: thin line art, transparent body pixels, ghostly low opacity, tiny head, overly realistic painting, chibi proportions, blurred edges, cropped bow limb, detached arrow fragments, labels, captions, watermarks, UI, grid lines, checker backgrounds, duplicated identical frames.
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- Source: `docs/handpaint-batch2-shu-archer-source-v1.png`
|
||||
- Contact sheet: `docs/handpaint-batch2-shu-archer-contact.png`
|
||||
- Animation GIF: `docs/handpaint-batch2-shu-archer-animation.gif`
|
||||
- Previous/new comparison: `docs/handpaint-batch2-shu-archer-before-after.png`
|
||||
- Battle-scale comparison: `docs/handpaint-batch2-shu-archer-scale-compare.png`
|
||||
- Battle map composite: `docs/handpaint-batch2-shu-archer-battle-composite.png`
|
||||
- Report: `docs/handpaint-batch2-shu-archer-report.md`
|
||||
|
||||
## Validation Targets
|
||||
|
||||
- Base partial alpha: `0`
|
||||
- Action partial alpha: `0`
|
||||
- Base frame border hits: `0`
|
||||
- Action frame border hits: `0`
|
||||
- Base sheet size: `5008x1252`
|
||||
- Action sheet size: `11268x1252`
|
||||
- `unit-shu-archer` must appear as `ok` in `docs/unit-sprite-rework-audit.json`
|
||||
- `unit-sprite-review-batch2-shu.png` must show `unit-shu-archer` as visually stronger than the old placeholder.
|
||||
65
docs/unit-sprite-batch2-shu-cavalry-spec.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Batch 2 Shu Cavalry Production Spec
|
||||
|
||||
Use this only after `unit-shu-infantry` v3 is approved and the current queue reaches `unit-shu-cavalry`.
|
||||
|
||||
## Target
|
||||
|
||||
- Unit key: `unit-shu-cavalry`
|
||||
- Batch: Batch 2 Shu generic units
|
||||
- Role: generic Shu horse cavalry
|
||||
- Style reference:
|
||||
- `unit-liu-bei`
|
||||
- `unit-guan-yu`
|
||||
- `unit-zhang-fei`
|
||||
- `unit-rebel-cavalry`
|
||||
- `unit-shu-infantry` v3
|
||||
- Required output sheets:
|
||||
- `src/assets/images/units/unit-shu-cavalry.png`
|
||||
- `src/assets/images/units/unit-shu-cavalry-actions.png`
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Must read as horse and rider at 50px battle scale.
|
||||
- Horse silhouette is the priority: head, chest, legs, saddle, and rider must read as one stable unit.
|
||||
- Rider should carry a short spear or saber without pushing the frame outside tile limits.
|
||||
- Use green Shu saddle cloth, bronze armor, tan/brown horse, dark outline, and large readable color blocks.
|
||||
- Movement must not drift sideways across frames; cavalry previously caused tile-boundary discomfort, so containment is strict.
|
||||
- Do not solve readability with base rings, badges, glows, or UI decoration.
|
||||
|
||||
## Source Image Prompt
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production source image for a tactical RPG mounted unit sprite sheet
|
||||
Primary request: Create a high-quality hand-painted raster contact sheet for ONE Shu cavalry unit, in the same production style as the approved hand-painted units but clearly differentiated by a compact horse-and-rider silhouette.
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background only, no shadows, no floor plane, no texture, no text.
|
||||
Subject: original Three Kingdoms-inspired Shu cavalry soldier, no copyrighted game assets, no logos. Brown warhorse with compact strong body, green Shu saddle cloth, bronze tack, mounted rider in green-and-bronze armor, short spear or saber, small cloak and dark boots. The character should look like an elite generic Shu cavalryman, not a named hero.
|
||||
Style/medium: premium hand-painted pixel-adjacent raster game art, bold dark ink outline, large readable color masses, crisp opaque body shapes, subtle painted armor and horse highlights, tactical RPG battle sprite source art.
|
||||
Composition/framing: 6 columns by 2 rows contact sheet, twelve separate full-body mounted frames, each frame centered with generous padding and the entire horse/rider visible. No cropping. Keep horse and rider around 76-84% of cell height and 78-88% of cell width, with stable body scale and ground position across frames.
|
||||
Frames left to right, top row: front idle horse facing forward, front walk step, east walk side, back walk, west walk side mirrored pose, attack windup with rider weapon raised. Bottom row: mounted strike, mounted follow-through, command gesture, item/strategy gesture, hurt recoil, victory pose with rider raising weapon.
|
||||
Animation constraints: each frame should be newly drawn with natural pose differences in horse legs, rider arms, reins, saddle cloth, and weapon, but total mounted silhouette size must remain stable. Idle should breathe subtly without twisting. Walk frames should not slide far horizontally. Action frames must stay inside their cell.
|
||||
Readability constraints: at 50px game scale the horse body, rider torso, green saddle cloth, and mounted weapon must read before the background. Use thick silhouette, deep shadows, strong outline, fewer tiny details, and clear mounted posture.
|
||||
Avoid: thin line art, transparent body pixels, ghostly low opacity, tiny horse legs, overly realistic painting, chibi proportions, blurred edges, cropped horse head, cropped weapon tip, detached leg/tail/weapon fragments, excessive sideways drift, labels, captions, watermarks, UI, grid lines, checker backgrounds, duplicated identical frames.
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- Source: `docs/handpaint-batch2-shu-cavalry-source-v1.png`
|
||||
- Contact sheet: `docs/handpaint-batch2-shu-cavalry-contact.png`
|
||||
- Animation GIF: `docs/handpaint-batch2-shu-cavalry-animation.gif`
|
||||
- Previous/new comparison: `docs/handpaint-batch2-shu-cavalry-before-after.png`
|
||||
- Battle-scale comparison: `docs/handpaint-batch2-shu-cavalry-scale-compare.png`
|
||||
- Battle map composite: `docs/handpaint-batch2-shu-cavalry-battle-composite.png`
|
||||
- Report: `docs/handpaint-batch2-shu-cavalry-report.md`
|
||||
|
||||
## Validation Targets
|
||||
|
||||
- Base partial alpha: `0`
|
||||
- Action partial alpha: `0`
|
||||
- Base frame border hits: `0`
|
||||
- Action frame border hits: `0`
|
||||
- Base sheet size: `5008x1252`
|
||||
- Action sheet size: `11268x1252`
|
||||
- `unit-shu-cavalry` must appear as `ok` in `docs/unit-sprite-rework-audit.json`
|
||||
- Cavalry must not visibly overflow or drift outside the square tile at battle scale.
|
||||
- `unit-sprite-review-batch2-shu.png` must show `unit-shu-cavalry` as visually stronger than the old placeholder.
|
||||
64
docs/unit-sprite-batch2-shu-officer-spec.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Batch 2 Shu Officer Production Spec
|
||||
|
||||
Use this only after `unit-shu-infantry` v3 is approved and the current queue reaches `unit-shu-officer`.
|
||||
|
||||
## Target
|
||||
|
||||
- Unit key: `unit-shu-officer`
|
||||
- Batch: Batch 2 Shu generic units
|
||||
- Role: generic Shu field officer
|
||||
- Style reference:
|
||||
- `unit-liu-bei`
|
||||
- `unit-guan-yu`
|
||||
- `unit-zhang-fei`
|
||||
- `unit-shu-infantry` v3
|
||||
- Required output sheets:
|
||||
- `src/assets/images/units/unit-shu-officer.png`
|
||||
- `src/assets/images/units/unit-shu-officer-actions.png`
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Must read as a command/officer unit at 50px battle scale, not as a basic infantry clone.
|
||||
- Command baton, banner tassel, sword, or formal shoulder armor should identify the class.
|
||||
- Use deeper green cloak/tabard, bronze officer armor, red-gold command accents, dark outline, and large readable color blocks.
|
||||
- Silhouette should be more upright and authoritative than infantry but less heroic than Liu Bei/Guan Yu/Zhang Fei.
|
||||
- Keep command props contained inside each 313px frame.
|
||||
- Do not solve readability with base rings, badges, glows, or UI decoration.
|
||||
|
||||
## Source Image Prompt
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production source image for a tactical RPG officer unit sprite sheet
|
||||
Primary request: Create a high-quality hand-painted raster contact sheet for ONE Shu field officer unit, in the same production style as the approved hand-painted units but clearly differentiated by command posture and officer equipment.
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background only, no shadows, no floor plane, no texture, no text.
|
||||
Subject: original Three Kingdoms-inspired Shu field officer, no copyrighted game assets, no logos. Deep green cloak or officer tabard, bronze lamellar armor, red-gold command sash, short sword or command baton, optional small back banner/tassel, sturdy boots, helmet or tied hair. The character should look like an elite generic Shu officer, not Liu Bei or any named hero.
|
||||
Style/medium: premium hand-painted pixel-adjacent raster game art, bold dark ink outline, large readable color masses, crisp opaque body shapes, subtle painted armor highlights and cloak folds, tactical RPG battle sprite source art.
|
||||
Composition/framing: 6 columns by 2 rows contact sheet, twelve separate full-body frames, each frame centered with generous padding and all command props visible. No cropping. Keep each figure around 80-86% of its cell height with stable body scale and ground position across all frames.
|
||||
Frames left to right, top row: front idle authoritative stance, front walk step, east walk side with cloak and sword/baton, back walk with cloak/banner visible, west walk side mirrored pose, attack windup with officer weapon raised. Bottom row: sword/baton strike, command sweep or follow-through, command gesture pointing forward, item/strategy gesture, hurt recoil, victory pose raising command item.
|
||||
Animation constraints: each frame should be newly drawn with natural pose differences in arms, legs, cloak, banner tassel, and weapon, but body size must remain stable. Idle should not twist sideways. Walk frames should not drift far horizontally. Action frames must stay inside their cell.
|
||||
Readability constraints: at 50px game scale the officer cloak/tabard, command prop, green Shu palette, and upright command silhouette must read before the background. Use thick silhouette, deep shadows, strong outline, fewer tiny details, and clear officer posture.
|
||||
Avoid: thin line art, transparent body pixels, ghostly low opacity, tiny head, overly realistic painting, chibi proportions, blurred edges, cropped banner or sword, detached tassel fragments, overly heroic named-character design, labels, captions, watermarks, UI, grid lines, checker backgrounds, duplicated identical frames.
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- Source: `docs/handpaint-batch2-shu-officer-source-v1.png`
|
||||
- Contact sheet: `docs/handpaint-batch2-shu-officer-contact.png`
|
||||
- Animation GIF: `docs/handpaint-batch2-shu-officer-animation.gif`
|
||||
- Previous/new comparison: `docs/handpaint-batch2-shu-officer-before-after.png`
|
||||
- Battle-scale comparison: `docs/handpaint-batch2-shu-officer-scale-compare.png`
|
||||
- Battle map composite: `docs/handpaint-batch2-shu-officer-battle-composite.png`
|
||||
- Report: `docs/handpaint-batch2-shu-officer-report.md`
|
||||
|
||||
## Validation Targets
|
||||
|
||||
- Base partial alpha: `0`
|
||||
- Action partial alpha: `0`
|
||||
- Base frame border hits: `0`
|
||||
- Action frame border hits: `0`
|
||||
- Base sheet size: `5008x1252`
|
||||
- Action sheet size: `11268x1252`
|
||||
- `unit-shu-officer` must appear as `ok` in `docs/unit-sprite-rework-audit.json`
|
||||
- Officer silhouette must read differently from infantry and strategist at 50px.
|
||||
- `unit-sprite-review-batch2-shu.png` must show `unit-shu-officer` as visually stronger than the old placeholder.
|
||||
64
docs/unit-sprite-batch2-shu-spearman-spec.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Batch 2 Shu Spearman Production Spec
|
||||
|
||||
Use this only after `unit-shu-infantry` v3 is approved.
|
||||
|
||||
## Target
|
||||
|
||||
- Unit key: `unit-shu-spearman`
|
||||
- Batch: Batch 2 Shu generic units
|
||||
- Role: generic Shu spear infantry
|
||||
- Style reference:
|
||||
- `unit-liu-bei`
|
||||
- `unit-guan-yu`
|
||||
- `unit-zhang-fei`
|
||||
- `unit-shu-infantry` v3
|
||||
- Required output sheets:
|
||||
- `src/assets/images/units/unit-shu-spearman.png`
|
||||
- `src/assets/images/units/unit-shu-spearman-actions.png`
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Must read as a spear unit at 50px battle scale.
|
||||
- Long spear silhouette should be visible in every side-facing combat/walk frame.
|
||||
- Body should be broad enough to match the v3 infantry quality bar, but slightly leaner than Zhang Fei.
|
||||
- Use green Shu cloth/armor with bronze/gold plates, dark outline, and large readable color blocks.
|
||||
- Use a sturdy foot-soldier stance, not a unique hero pose.
|
||||
- Keep the spear contained inside each 313px frame; no detached shaft/tip fragments.
|
||||
- Do not solve readability with base rings, badges, glows, or UI decoration.
|
||||
|
||||
## Source Image Prompt
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production source image for a tactical RPG unit sprite sheet
|
||||
Primary request: Create a high-quality hand-painted raster contact sheet for ONE Shu spearman unit, in the same production style as the approved Shu infantry v3 sample but clearly differentiated by a long spear silhouette.
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background only, no shadows, no floor plane, no texture, no text.
|
||||
Subject: original Three Kingdoms-inspired Shu spear infantry, not Guan Yu, not Zhang Fei, no copyrighted game assets, no logos. Green-and-bronze lamellar armor, long spear/qiang, compact round or small strapped shield only if it does not obscure the spear, sturdy boots, dark hair topknot or helmet, small cloak/tabard. The character should look like an elite generic Shu spear soldier.
|
||||
Style/medium: premium hand-painted pixel-adjacent raster game art, bold dark ink outline, large readable color masses, crisp opaque body shapes, subtle painted armor highlights, tactical RPG battle sprite source art.
|
||||
Composition/framing: 6 columns by 2 rows contact sheet, twelve separate full-body frames, each frame centered with generous padding and the entire spear visible. No cropping. Keep each figure around 78-84% of its cell height with stable body scale and ground position across all frames.
|
||||
Frames left to right, top row: front idle with spear upright, front walk step, east walk side with spear angled forward, back walk with spear visible behind shoulder, west walk side mirrored pose, attack windup with spear pulled back. Bottom row: spear thrust with readable forward line, sweeping spear slash, command gesture, item/strategy gesture, hurt recoil, victory pose.
|
||||
Animation constraints: each frame should be newly drawn with natural pose differences in arms, legs, spear, cloak and cloth folds, but body size must remain stable. Idle should not twist sideways. Walk frames should not drift far horizontally. Action frames must stay inside their cell.
|
||||
Readability constraints: at 50px game scale the spear line, green Shu armor, and infantry body block must read before the background. Use thicker silhouette, deeper shadows, stronger outline, fewer tiny details, clear weapon shape.
|
||||
Avoid: thin line art, transparent body pixels, ghostly low opacity, tiny head, overly realistic painting, chibi proportions, blurred edges, cropped spear tip, detached spear fragments, labels, captions, watermarks, UI, grid lines, checker backgrounds, duplicated identical frames.
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- Source: `docs/handpaint-batch2-shu-spearman-source-v1.png`
|
||||
- Contact sheet: `docs/handpaint-batch2-shu-spearman-contact.png`
|
||||
- Animation GIF: `docs/handpaint-batch2-shu-spearman-animation.gif`
|
||||
- Previous/new comparison: `docs/handpaint-batch2-shu-spearman-before-after.png`
|
||||
- Battle-scale comparison: `docs/handpaint-batch2-shu-spearman-scale-compare.png`
|
||||
- Battle map composite: `docs/handpaint-batch2-shu-spearman-battle-composite.png`
|
||||
- Report: `docs/handpaint-batch2-shu-spearman-report.md`
|
||||
|
||||
## Validation Targets
|
||||
|
||||
- Base partial alpha: `0`
|
||||
- Action partial alpha: `0`
|
||||
- Base frame border hits: `0`
|
||||
- Action frame border hits: `0`
|
||||
- Base sheet size: `5008x1252`
|
||||
- Action sheet size: `11268x1252`
|
||||
- `unit-shu-spearman` must appear as `ok` in `docs/unit-sprite-rework-audit.json`
|
||||
- `unit-sprite-review-batch2-shu.png` must show `unit-shu-spearman` as visually stronger than the old placeholder.
|
||||
63
docs/unit-sprite-batch2-shu-strategist-spec.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Batch 2 Shu Strategist Production Spec
|
||||
|
||||
Use this only after `unit-shu-infantry` v3 is approved and the current queue reaches `unit-shu-strategist`.
|
||||
|
||||
## Target
|
||||
|
||||
- Unit key: `unit-shu-strategist`
|
||||
- Batch: Batch 2 Shu generic units
|
||||
- Role: generic Shu tactician/strategist
|
||||
- Style reference:
|
||||
- `unit-liu-bei`
|
||||
- `unit-shu-infantry` v3
|
||||
- approved hand-painted batch 1 readability
|
||||
- Required output sheets:
|
||||
- `src/assets/images/units/unit-shu-strategist.png`
|
||||
- `src/assets/images/units/unit-shu-strategist-actions.png`
|
||||
|
||||
## Visual Direction
|
||||
|
||||
- Must read as a strategist at 50px battle scale, not as a plain infantry unit.
|
||||
- Fan, scroll, robe silhouette, and calm upright posture should identify the class.
|
||||
- Use green/cream Shu robes with bronze belt details and a dark outline.
|
||||
- Keep robes broad enough to read, with simple large folds and no wispy transparent edges.
|
||||
- Strategy action frames may include contained painted effect shapes, but no detached idle/walk fragments.
|
||||
- Do not solve readability with base rings, badges, glows, or UI decoration.
|
||||
|
||||
## Source Image Prompt
|
||||
|
||||
```text
|
||||
Use case: stylized-concept
|
||||
Asset type: production source image for a tactical RPG strategist unit sprite sheet
|
||||
Primary request: Create a high-quality hand-painted raster contact sheet for ONE Shu strategist unit, in the same production style as the approved hand-painted units but clearly differentiated by fan, scroll, and robe silhouette.
|
||||
Scene/backdrop: perfectly flat solid #ff00ff chroma-key background only, no shadows, no floor plane, no texture, no text.
|
||||
Subject: original Three Kingdoms-inspired Shu strategist, no copyrighted game assets, no logos. Green and cream layered robes, bronze belt plate, dark boots, scholar cap or tied hair, feather fan or folded war fan, small scroll case. The character should look like an elite generic Shu tactician, not Zhuge Liang or another named hero.
|
||||
Style/medium: premium hand-painted pixel-adjacent raster game art, bold dark ink outline, large readable color masses, crisp opaque body shapes, subtle painted robe folds, tactical RPG battle sprite source art.
|
||||
Composition/framing: 6 columns by 2 rows contact sheet, twelve separate full-body frames, each frame centered with generous padding and the entire fan/scroll visible. No cropping. Keep each figure around 78-84% of its cell height with stable body scale and ground position across all frames.
|
||||
Frames left to right, top row: front idle holding fan, front walk step, east walk side with robe and fan visible, back walk with scroll case, west walk side mirrored pose, strategy windup raising fan. Bottom row: strategy cast with contained scroll/fan gesture, second strategy pose with robe sweep, command gesture, item gesture, hurt recoil, victory pose with fan lifted.
|
||||
Animation constraints: each frame should be newly drawn with natural pose differences in sleeves, robe hem, fan angle, head, and arms, but body size must remain stable. Idle should not twist sideways. Walk frames should not drift far horizontally. Action frames must stay inside their cell.
|
||||
Readability constraints: at 50px game scale the fan shape, robe mass, green Shu palette, and strategist posture must read before the background. Use thick silhouette, deep shadows, strong outline, fewer tiny details, and clear class props.
|
||||
Avoid: thin line art, transparent body pixels, ghostly low opacity, tiny head, overly realistic painting, chibi proportions, blurred edges, cropped fan, detached spell fragments in idle/walk, excessive glowing effects, labels, captions, watermarks, UI, grid lines, checker backgrounds, duplicated identical frames.
|
||||
```
|
||||
|
||||
## Expected Artifacts
|
||||
|
||||
- Source: `docs/handpaint-batch2-shu-strategist-source-v1.png`
|
||||
- Contact sheet: `docs/handpaint-batch2-shu-strategist-contact.png`
|
||||
- Animation GIF: `docs/handpaint-batch2-shu-strategist-animation.gif`
|
||||
- Previous/new comparison: `docs/handpaint-batch2-shu-strategist-before-after.png`
|
||||
- Battle-scale comparison: `docs/handpaint-batch2-shu-strategist-scale-compare.png`
|
||||
- Battle map composite: `docs/handpaint-batch2-shu-strategist-battle-composite.png`
|
||||
- Report: `docs/handpaint-batch2-shu-strategist-report.md`
|
||||
|
||||
## Validation Targets
|
||||
|
||||
- Base partial alpha: `0`
|
||||
- Action partial alpha: `0`
|
||||
- Base frame border hits: `0`
|
||||
- Action frame border hits: `0`
|
||||
- Base sheet size: `5008x1252`
|
||||
- Action sheet size: `11268x1252`
|
||||
- `unit-shu-strategist` must appear as `ok` in `docs/unit-sprite-rework-audit.json`
|
||||
- Fan/robe silhouette must be readable at 50px without relying on effects.
|
||||
- `unit-sprite-review-batch2-shu.png` must show `unit-shu-strategist` as visually stronger than the old placeholder.
|
||||
156
docs/unit-sprite-handpaint-pipeline-guide.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Hand-Painted Sprite Pipeline Guide
|
||||
|
||||
This guide captures the current approved/pending workflow so future unit work stays consistent.
|
||||
|
||||
## Current Gate
|
||||
|
||||
- Review state manifest: `unit-sprite-rework-manifest.json`.
|
||||
- Do not produce the next unit until `unit-shu-infantry` v3 is approved.
|
||||
- Do not commit, push, or deploy while a sample is pending approval.
|
||||
- Current pending sample: `docs/unit-sprite-shu-infantry-v3-approval-packet.md`.
|
||||
- Current visual approval board: `docs/unit-sprite-current-approval-board.png`.
|
||||
- Next prepared target after approval: `docs/unit-sprite-batch2-shu-spearman-spec.md`.
|
||||
|
||||
## Source Image Contract
|
||||
|
||||
Generate each unit as a 6 column x 2 row contact sheet on a flat `#ff00ff` background.
|
||||
|
||||
Required 12 cells:
|
||||
|
||||
1. front idle
|
||||
2. front walk step
|
||||
3. east walk side
|
||||
4. back walk
|
||||
5. west walk side
|
||||
6. attack windup
|
||||
7. attack strike 1
|
||||
8. attack strike 2
|
||||
9. command gesture
|
||||
10. item/strategy gesture
|
||||
11. hurt recoil
|
||||
12. victory pose
|
||||
|
||||
The source image must have:
|
||||
|
||||
- full-body figures, no cropping
|
||||
- stable scale across cells
|
||||
- no labels, UI, grid lines, watermark, or checker background
|
||||
- weapon/class silhouette visible at 50px
|
||||
- enough padding that weapon tips can be contained in 313px frames
|
||||
|
||||
## Sheet Assembly Contract
|
||||
|
||||
The generated source is converted into:
|
||||
|
||||
- Base sheet: `5008x1252`, 4 rows x 16 frames
|
||||
- Action sheet: `11268x1252`, 4 rows x 36 frames
|
||||
- Frame size: `313x313`
|
||||
- Direction rows: south, east, north, west
|
||||
- Base columns: 8 idle + 8 walk per direction
|
||||
- Action groups: attack 10, strategy 8, item 8, hurt 4, celebrate 6
|
||||
|
||||
The current reference implementation is `scripts/assemble-handpaint-shu-infantry-quality-sample.py`.
|
||||
|
||||
Default execution regenerates the pending Shu infantry v3 sample:
|
||||
|
||||
```powershell
|
||||
python scripts\assemble-handpaint-shu-infantry-quality-sample.py
|
||||
```
|
||||
|
||||
After `unit-shu-infantry` v3 is approved, reuse the same assembler for the next unit by changing only the source, stem, titles, labels, and output prefix. Example for the prepared Shu spearman target:
|
||||
|
||||
```powershell
|
||||
python scripts\assemble-handpaint-shu-infantry-quality-sample.py `
|
||||
--source docs\handpaint-batch2-shu-spearman-source-v1.png `
|
||||
--stem unit-shu-spearman `
|
||||
--work-dir tmp\handpaint-shu-spearman `
|
||||
--output-prefix handpaint-batch2-shu-spearman `
|
||||
--contact-title "Shu Spearman Hand-Painted Sample" `
|
||||
--before-title "Shu Spearman Previous / Hand-Painted Sample" `
|
||||
--before-scale-label "Previous / new at battle scale" `
|
||||
--report-title "Shu Spearman Hand-Painted Sample" `
|
||||
--sample-label "shu spear" `
|
||||
--compare-stems unit-liu-bei,unit-guan-yu,unit-zhang-fei,unit-shu-infantry,unit-shu-spearman `
|
||||
--compare-labels liu-bei,guan-yu,zhang-fei,shu-infantry,shu-spearman
|
||||
```
|
||||
|
||||
## Extraction Rules
|
||||
|
||||
- Remove chroma-key background.
|
||||
- Despill magenta edge color.
|
||||
- Convert body pixels to fully opaque alpha.
|
||||
- Keep detached effects only for explicitly reviewed action frames.
|
||||
- Idle/walk frames should keep only the main connected body component.
|
||||
- Mirror east-facing poses for west only when the generated west pose is not reliable.
|
||||
- Keep feet/bottom alignment stable.
|
||||
|
||||
## Required Commands
|
||||
|
||||
After assembling a unit:
|
||||
|
||||
```powershell
|
||||
python scripts\audit-unit-sprite-sheets.py
|
||||
python scripts\render-unit-sprite-review-boards.py
|
||||
python scripts\check-unit-sprite-art-quality.py
|
||||
python scripts\check-unit-sprite-approval-gate.py --mode sample
|
||||
python scripts\write-unit-sprite-next-steps.py
|
||||
python scripts\check-unit-sprite-workflow-consistency.py
|
||||
python -m py_compile scripts\audit-unit-sprite-sheets.py scripts\render-unit-sprite-review-boards.py scripts\assemble-handpaint-shu-infantry-quality-sample.py
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Then verify in a PC browser preview and confirm console errors are `0`.
|
||||
|
||||
For pending sample approval, open the local battle with a debug-only sprite preview:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:4173/heros_web/?debug&debugSpritePreview=unit-shu-infantry&v=shu-infantry-debug-preview
|
||||
```
|
||||
|
||||
The `debugSpritePreview` panel renders the requested unit at 72px, 50px, and one action frame inside the real battle scene. It is only shown when debug tools are enabled and does not appear during normal play.
|
||||
|
||||
## Visual Review
|
||||
|
||||
Always inspect:
|
||||
|
||||
- per-unit contact sheet
|
||||
- animation GIF
|
||||
- previous/new comparison
|
||||
- 50px/72px scale comparison
|
||||
- battle map composite
|
||||
- debug sprite preview battle screenshot, when reviewing a pending sample
|
||||
- current approval board, `docs/unit-sprite-current-approval-board.png`
|
||||
- relevant batch inventory board, for example `docs/unit-sprite-review-batch2-shu.png`
|
||||
|
||||
The current approval board is the primary artifact for judging whether a pending sample is good enough. Batch inventory boards are secondary; they include old queued assets by design and must not be read as completed deliverables.
|
||||
|
||||
The audit distinguishes technical validity from art approval:
|
||||
|
||||
- `approved-baseline`: approved hand-painted reference units.
|
||||
- `pending-approval`: a prepared sample that must not be expanded, committed, pushed, or deployed yet.
|
||||
- `queued-rework`: units still waiting for hand-painted rework, even if their sheet dimensions are technically valid.
|
||||
|
||||
These stages are loaded from `unit-sprite-rework-manifest.json`. Technical audit results and art approval must remain separate.
|
||||
|
||||
Use `python scripts\check-unit-sprite-approval-gate.py --mode sample` while a sample is awaiting approval. Use `--mode release` only after approval and manifest promotion; release mode intentionally fails while `pendingApproval` is non-empty.
|
||||
|
||||
After explicit user approval, promote the approved pending sample with the helper script instead of editing the manifest by hand:
|
||||
|
||||
```powershell
|
||||
python scripts\promote-unit-sprite-approval.py --unit unit-shu-infantry --expected-version v3
|
||||
python scripts\audit-unit-sprite-sheets.py
|
||||
python scripts\render-unit-sprite-review-boards.py
|
||||
python scripts\check-unit-sprite-approval-gate.py --mode release
|
||||
```
|
||||
|
||||
## Acceptance Bar
|
||||
|
||||
A unit can move to approval only when:
|
||||
|
||||
- it is visibly stronger than the old placeholder
|
||||
- it reads at 50px without rings/badges/glows
|
||||
- it has no ghostly transparency
|
||||
- it has no detached idle/walk fragments
|
||||
- it passes the audit script as `ok`
|
||||
- it passes `scripts/check-unit-sprite-art-quality.py`
|
||||
- it matches the approved hand-painted style direction
|
||||
50
docs/unit-sprite-next-steps.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Unit Sprite Next Steps
|
||||
|
||||
This file is generated by `scripts/write-unit-sprite-next-steps.py`.
|
||||
|
||||
## Current Gate
|
||||
|
||||
- Approved baseline units: 7
|
||||
- Pending approval units: 0
|
||||
- Queued rework units: 84
|
||||
- Manifest: `docs/unit-sprite-rework-manifest.json`
|
||||
- Audit: `docs/unit-sprite-rework-audit.json`
|
||||
- Current approval board: not generated until the next pending sample.
|
||||
- Batch inventory boards include old queued assets; use them for inventory/regression checks, not approval.
|
||||
|
||||
## Next Unit Queue
|
||||
|
||||
1. `unit-shu-spearman` - queued-rework, base `border-hits=32`, actions `border-hits=82`, spec `unit-sprite-batch2-shu-spearman-spec.md`
|
||||
2. `unit-shu-archer` - queued-rework, base `border-hits=64`, actions `border-hits=140`, spec `unit-sprite-batch2-shu-archer-spec.md`
|
||||
3. `unit-shu-cavalry` - queued-rework, base `border-hits=64`, actions `border-hits=144`, spec `unit-sprite-batch2-shu-cavalry-spec.md`
|
||||
4. `unit-shu-strategist` - queued-rework, base `border-hits=32`, actions `border-hits=98`, spec `unit-sprite-batch2-shu-strategist-spec.md`
|
||||
5. `unit-shu-officer` - queued-rework, base `border-hits=32`, actions `border-hits=82`, spec `unit-sprite-batch2-shu-officer-spec.md`
|
||||
|
||||
## Commands Before Approval
|
||||
|
||||
```powershell
|
||||
python scripts\audit-unit-sprite-sheets.py
|
||||
python scripts\render-unit-sprite-review-boards.py
|
||||
python scripts\render-current-sprite-approval-board.py
|
||||
python scripts\check-unit-sprite-art-quality.py
|
||||
python scripts\check-unit-sprite-approval-gate.py --mode sample
|
||||
python scripts\write-unit-sprite-next-steps.py
|
||||
python scripts\check-unit-sprite-workflow-consistency.py
|
||||
```
|
||||
|
||||
## Commands After Approval
|
||||
|
||||
After user approval, promote the approved unit in `unit-sprite-rework-manifest.json`, regenerate outputs, and only then use the release gate.
|
||||
|
||||
```powershell
|
||||
python scripts\audit-unit-sprite-sheets.py
|
||||
python scripts\render-unit-sprite-review-boards.py
|
||||
python scripts\check-unit-sprite-approval-gate.py --mode release
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## First Task After Current Approval
|
||||
|
||||
- Start with `unit-shu-spearman`.
|
||||
- Use spec: `unit-sprite-batch2-shu-spearman-spec.md`.
|
||||
- Generate source art, assemble sheets, produce contact/GIF/before-after/scale/battle screenshots, then rerun the gates.
|
||||
BIN
docs/unit-sprite-review-approved-and-pending.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
docs/unit-sprite-review-batch1-approved-core.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/unit-sprite-review-batch1-approved-rebel.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/unit-sprite-review-batch2-shu.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/unit-sprite-review-batch3-wei.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/unit-sprite-review-batch4-wu.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/unit-sprite-review-batch5-rebel-other.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/unit-sprite-review-batch6-nanman-special.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/unit-sprite-review-batch7-unique-officer.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/unit-sprite-review-index.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
5206
docs/unit-sprite-rework-audit.json
Normal file
145
docs/unit-sprite-rework-audit.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Unit Sprite Rework Audit
|
||||
|
||||
This file is generated by `scripts/audit-unit-sprite-sheets.py`.
|
||||
|
||||
Review stages are loaded from `docs/unit-sprite-rework-manifest.json`.
|
||||
|
||||
Visual review boards are generated by `scripts/render-unit-sprite-review-boards.py`.
|
||||
|
||||
- Review index: `unit-sprite-review-index.png`
|
||||
- Batch 1 core: `unit-sprite-review-batch1-approved-core.png`
|
||||
- Batch 1 rebel: `unit-sprite-review-batch1-approved-rebel.png`
|
||||
- Batch 2 Shu: `unit-sprite-review-batch2-shu.png`
|
||||
- Batch 3 Wei: `unit-sprite-review-batch3-wei.png`
|
||||
- Batch 4 Wu: `unit-sprite-review-batch4-wu.png`
|
||||
- Batch 5 rebel other: `unit-sprite-review-batch5-rebel-other.png`
|
||||
- Batch 6 Nanman/special: `unit-sprite-review-batch6-nanman-special.png`
|
||||
- Batch 7 unique officer: `unit-sprite-review-batch7-unique-officer.png`
|
||||
|
||||
## Batch Counts
|
||||
|
||||
- batch1-approved-core: 3
|
||||
- batch1-approved-rebel: 3
|
||||
- batch2-shu: 18
|
||||
- batch3-wei: 16
|
||||
- batch4-wu: 16
|
||||
- batch5-rebel-other: 6
|
||||
- batch6-nanman-special: 10
|
||||
- batch7-unique-officer: 19
|
||||
|
||||
## Review Stage Counts
|
||||
|
||||
- approved-baseline: 7
|
||||
- queued-rework: 84
|
||||
|
||||
## Batch Plan
|
||||
|
||||
- batch1-approved-core/rebel: already approved baseline, use as quality reference.
|
||||
- batch2-shu: Shu generic infantry/spearman/archer/cavalry/strategist/officer variants.
|
||||
- batch3-wei: Wei generic infantry/archer/cavalry/strategist/officer variants.
|
||||
- batch4-wu: Wu generic infantry/archer/cavalry/strategist/officer variants.
|
||||
- batch5-rebel-other: remaining rebel/bandit/yellow-turban variants outside approved batch1.
|
||||
- batch6-nanman-special: Nanman and special tribal unit variants.
|
||||
- batch7-unique-officer: unique officer sheets not covered by earlier batches.
|
||||
|
||||
## Review Stage
|
||||
|
||||
- `approved-baseline`: approved hand-painted reference units.
|
||||
- `pending-approval`: technically prepared, waiting for user approval before expansion/commit/deploy.
|
||||
- `queued-rework`: listed for later hand-painted rework; technical `ok` does not mean visually approved.
|
||||
|
||||
## Sheet Status
|
||||
|
||||
| batch | stage | stem | base | actions | base bbox | action bbox | battle base px | motion swing |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| batch7-unique-officer | queued-rework | `unit-cao-cao` | border-hits=64 | border-hits=144 | 208..271 x 282..288 | 208..280 x 275..290 | 33.2..43.3 x 45.0..46.0 | w63 h6 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-fa-zheng` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-gong-zhi` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch1-approved-core | approved-baseline | `unit-guan-yu` | ok | ok | 190..270 x 276..291 | 172..296 x 171..289 | 30.4..43.1 x 44.1..46.5 | w80 h15 b4 |
|
||||
| batch7-unique-officer | queued-rework | `unit-huang-quan` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-huang-zhong` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-jiang-wei` | border-hits=32 | border-hits=82 | 194..254 x 279..279 | 194..263 x 275..279 | 31.0..40.6 x 44.6..44.6 | w60 h0 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-li-yan` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch1-approved-core | approved-baseline | `unit-liu-bei` | ok | ok | 138..258 x 278..289 | 148..300 x 252..284 | 22.0..41.2 x 44.4..46.2 | w120 h11 b12 |
|
||||
| batch7-unique-officer | queued-rework | `unit-lu-bu` | border-hits=64 | border-hits=144 | 180..296 x 282..288 | 180..305 x 274..290 | 28.8..47.3 x 45.0..46.0 | w116 h6 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-lu-meng` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-ma-chao` | border-hits=64 | border-hits=144 | 166..296 x 282..288 | 166..305 x 274..290 | 26.5..47.3 x 45.0..46.0 | w130 h6 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-ma-dai` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch7-unique-officer | queued-rework | `unit-ma-liang` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-meng-huo` | border-hits=32 | border-hits=82 | 208..285 x 279..279 | 208..294 x 275..279 | 33.2..45.5 x 44.6..44.6 | w77 h0 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-infantry` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-infantry-jungle` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-infantry-spear` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-officer` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-officer-bronze` | border-hits=64 | border-hits=144 | 208..258 x 282..288 | 208..267 x 275..290 | 33.2..41.2 x 45.0..46.0 | w50 h6 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-officer-warlord` | border-hits=64 | border-hits=144 | 224..266 x 282..288 | 224..275 x 275..290 | 35.8..42.5 x 45.0..46.0 | w42 h6 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-shaman` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-shaman-ember` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch6-nanman-special | queued-rework | `unit-nanman-shaman-storm` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-pang-tong` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch1-approved-rebel | approved-baseline | `unit-rebel` | ok | ok | 195..284 x 271..281 | 180..286 x 178..289 | 31.2..45.4 x 43.3..44.9 | w89 h10 b8 |
|
||||
| batch1-approved-rebel | approved-baseline | `unit-rebel-archer` | ok | ok | 185..263 x 272..278 | 116..284 x 228..288 | 29.6..42.0 x 43.5..44.4 | w78 h6 b1 |
|
||||
| batch5-rebel-other | queued-rework | `unit-rebel-archer-fire` | border-hits=64 | border-hits=140 | 245..246 x 266..266 | 220..255 x 265..272 | 39.1..39.3 x 42.5..42.5 | w1 h0 b6 |
|
||||
| batch5-rebel-other | queued-rework | `unit-rebel-archer-veteran` | border-hits=64 | border-hits=140 | 249..250 x 276..277 | 223..259 x 275..278 | 39.8..39.9 x 44.1..44.2 | w1 h1 b6 |
|
||||
| batch1-approved-rebel | approved-baseline | `unit-rebel-cavalry` | ok | ok | 139..289 x 266..281 | 167..286 x 186..282 | 22.2..46.2 x 42.5..44.9 | w150 h15 b3 |
|
||||
| batch5-rebel-other | queued-rework | `unit-rebel-cavalry-veteran` | border-hits=64 | border-hits=144 | 166..296 x 282..288 | 166..305 x 274..290 | 26.5..47.3 x 45.0..46.0 | w130 h6 b6 |
|
||||
| batch5-rebel-other | queued-rework | `unit-rebel-leader` | border-hits=32 | border-hits=72 | 200..254 x 266..266 | 200..263 x 265..266 | 31.9..40.6 x 42.5..42.5 | w54 h0 b6 |
|
||||
| batch5-rebel-other | queued-rework | `unit-rebel-leader-warlord` | border-hits=32 | border-hits=78 | 224..266 x 276..277 | 224..275 x 275..277 | 35.8..42.5 x 44.1..44.2 | w42 h1 b6 |
|
||||
| batch5-rebel-other | queued-rework | `unit-rebel-ragged` | border-hits=32 | border-hits=72 | 192..277 x 266..266 | 192..286 x 265..266 | 30.7..44.2 x 42.5..42.5 | w85 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-archer` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-archer-longbow` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-archer-veteran` | border-hits=64 | border-hits=144 | 249..250 x 282..288 | 223..259 x 275..290 | 39.8..39.9 x 45.0..46.0 | w1 h6 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-cavalry` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch2-shu | queued-rework | `unit-shu-cavalry-scout` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch2-shu | queued-rework | `unit-shu-cavalry-white` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch2-shu | approved-baseline | `unit-shu-infantry` | ok | ok | 183..222 x 288..288 | 181..255 x 280..288 | 29.2..35.5 x 46.0..46.0 | w39 h0 b0 |
|
||||
| batch2-shu | queued-rework | `unit-shu-infantry-banner` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-infantry-guard` | border-hits=64 | border-hits=144 | 198..280 x 282..288 | 198..289 x 275..290 | 31.6..44.7 x 45.0..46.0 | w82 h6 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-officer` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-officer-council` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-officer-vanguard` | border-hits=64 | border-hits=144 | 208..258 x 282..288 | 208..267 x 275..290 | 33.2..41.2 x 45.0..46.0 | w50 h6 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-spearman` | border-hits=32 | border-hits=82 | 194..254 x 279..279 | 194..263 x 275..279 | 31.0..40.6 x 44.6..44.6 | w60 h0 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-spearman-guard` | border-hits=64 | border-hits=144 | 202..258 x 282..288 | 202..267 x 275..290 | 32.3..41.2 x 45.0..46.0 | w56 h6 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-spearman-veteran` | border-hits=64 | border-hits=144 | 202..258 x 282..288 | 202..267 x 275..290 | 32.3..41.2 x 45.0..46.0 | w56 h6 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-strategist` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-strategist-field` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch2-shu | queued-rework | `unit-shu-strategist-white` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-sima-yi` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-wang-ping` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-archer` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-archer-crossbow` | border-hits=64 | border-hits=144 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-archer-veteran` | border-hits=64 | border-hits=144 | 249..250 x 282..288 | 223..259 x 275..290 | 39.8..39.9 x 45.0..46.0 | w1 h6 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-cavalry` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch3-wei | queued-rework | `unit-wei-cavalry-elite` | border-hits=64 | border-hits=144 | 166..296 x 282..288 | 166..305 x 274..290 | 26.5..47.3 x 45.0..46.0 | w130 h6 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-cavalry-iron` | border-hits=64 | border-hits=144 | 166..296 x 282..288 | 166..305 x 274..290 | 26.5..47.3 x 45.0..46.0 | w130 h6 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-infantry` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-infantry-shield` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-infantry-veteran` | border-hits=64 | border-hits=144 | 198..280 x 282..288 | 198..289 x 275..290 | 31.6..44.7 x 45.0..46.0 | w82 h6 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-officer` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-officer-gate` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-officer-iron` | border-hits=64 | border-hits=144 | 208..258 x 282..288 | 208..267 x 275..290 | 33.2..41.2 x 45.0..46.0 | w50 h6 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-strategist` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-strategist-black` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-strategist-blue` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch3-wei | queued-rework | `unit-wei-yan` | border-hits=32 | border-hits=82 | 194..254 x 279..279 | 194..263 x 275..279 | 31.0..40.6 x 44.6..44.6 | w60 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-archer` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-archer-naval` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-archer-river` | border-hits=64 | border-hits=140 | 245..246 x 279..279 | 220..255 x 275..279 | 39.1..39.3 x 44.6..44.6 | w1 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-cavalry` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch4-wu | queued-rework | `unit-wu-cavalry-elite` | border-hits=64 | border-hits=144 | 166..296 x 282..288 | 166..305 x 274..290 | 26.5..47.3 x 45.0..46.0 | w130 h6 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-cavalry-river` | border-hits=64 | border-hits=144 | 160..291 x 282..289 | 160..300 x 274..290 | 25.6..46.5 x 45.0..46.2 | w131 h7 b7 |
|
||||
| batch4-wu | queued-rework | `unit-wu-infantry` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-infantry-marine` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-infantry-river` | border-hits=32 | border-hits=82 | 192..277 x 279..279 | 192..286 x 275..279 | 30.7..44.2 x 44.6..44.6 | w85 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-officer` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-officer-harbor` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-officer-river` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-strategist` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-strategist-fire` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-strategist-river` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch4-wu | queued-rework | `unit-wu-yi` | border-hits=32 | border-hits=82 | 200..254 x 279..279 | 200..263 x 275..279 | 31.9..40.6 x 44.6..44.6 | w54 h0 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-yan-yan` | border-hits=32 | border-hits=82 | 194..254 x 279..279 | 194..263 x 275..279 | 31.0..40.6 x 44.6..44.6 | w60 h0 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-yi-ji` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
| batch1-approved-core | approved-baseline | `unit-zhang-fei` | ok | ok | 143..306 x 274..289 | 188..286 x 181..290 | 22.8..48.9 x 43.8..46.2 | w163 h15 b14 |
|
||||
| batch7-unique-officer | queued-rework | `unit-zhao-yun` | border-hits=64 | border-hits=144 | 166..296 x 282..288 | 166..305 x 274..290 | 26.5..47.3 x 45.0..46.0 | w130 h6 b6 |
|
||||
| batch7-unique-officer | queued-rework | `unit-zhuge-liang` | border-hits=32 | border-hits=98 | 243..246 x 280..281 | 220..255 x 275..281 | 38.8..39.3 x 44.7..44.9 | w3 h1 b6 |
|
||||
36
docs/unit-sprite-rework-manifest.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"baselineCommit": "3092b295 Improve handpainted battle unit readability",
|
||||
"approvalPolicy": {
|
||||
"holdExpansionUntilCurrentSampleApproved": true,
|
||||
"holdCommitPushDeployUntilBatchApproved": true,
|
||||
"notes": [
|
||||
"Do not treat technical sheet validity as hand-painted art approval.",
|
||||
"Move a unit from pendingApproval to approvedBaseline only after user approval.",
|
||||
"Keep untouched or lower-quality units queuedRework even when their PNG dimensions are technically valid."
|
||||
]
|
||||
},
|
||||
"approvedBaseline": [
|
||||
"unit-guan-yu",
|
||||
"unit-liu-bei",
|
||||
"unit-rebel",
|
||||
"unit-rebel-archer",
|
||||
"unit-rebel-cavalry",
|
||||
"unit-shu-infantry",
|
||||
"unit-zhang-fei"
|
||||
],
|
||||
"pendingApproval": [],
|
||||
"currentApprovalCandidate": {},
|
||||
"nextAfterApproval": [
|
||||
"unit-shu-spearman",
|
||||
"unit-shu-archer",
|
||||
"unit-shu-cavalry",
|
||||
"unit-shu-strategist",
|
||||
"unit-shu-officer"
|
||||
],
|
||||
"reviewStages": {
|
||||
"approved-baseline": "Approved hand-painted reference or approved completed unit.",
|
||||
"pending-approval": "Prepared sample waiting for user approval; do not expand, commit, push, or deploy.",
|
||||
"queued-rework": "Still waiting for hand-painted rework; technical OK does not mean visually approved."
|
||||
}
|
||||
}
|
||||
97
docs/unit-sprite-rework-plan.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Hand-Painted Unit Sprite Rework Plan
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Approved baseline commit: `3092b295 Improve handpainted battle unit readability`
|
||||
- Approved batch 1 style references:
|
||||
- `unit-liu-bei`
|
||||
- `unit-guan-yu`
|
||||
- `unit-zhang-fei`
|
||||
- `unit-rebel`
|
||||
- `unit-rebel-archer`
|
||||
- `unit-rebel-cavalry`
|
||||
- Pending quality restart sample:
|
||||
- `unit-shu-infantry`
|
||||
- Source: `handpaint-batch2-shu-infantry-quality-restart-source-v3.png`
|
||||
- Contact sheet: `handpaint-batch2-shu-infantry-quality-restart-v3-contact.png`
|
||||
- Battle-scale composite: `handpaint-batch2-shu-infantry-quality-restart-v3-battle-composite.png`
|
||||
- Approval packet: `unit-sprite-shu-infantry-v3-approval-packet.md`
|
||||
|
||||
## Batch Inventory
|
||||
|
||||
Generated by `scripts/audit-unit-sprite-sheets.py`.
|
||||
|
||||
Visual review boards are generated by `scripts/render-unit-sprite-review-boards.py`.
|
||||
|
||||
- Review index: `unit-sprite-review-index.png`
|
||||
- Approved/pending deliverables only: `unit-sprite-review-approved-and-pending.png`
|
||||
- Current Shu batch board: `unit-sprite-review-batch2-shu.png`
|
||||
- Review manifest: `unit-sprite-rework-manifest.json`
|
||||
- Output checklist: `unit-sprite-batch-output-checklist.md`
|
||||
- Pipeline guide: `unit-sprite-handpaint-pipeline-guide.md`
|
||||
- Progress log: `unit-sprite-rework-progress-log.md`
|
||||
- Next-step report: `unit-sprite-next-steps.md`
|
||||
|
||||
- Batch 1 approved core: 3 sheets
|
||||
- Batch 1 approved rebel: 3 sheets
|
||||
- Batch 2 Shu generic: 18 sheets
|
||||
- Batch 3 Wei generic: 16 sheets
|
||||
- Batch 4 Wu generic: 16 sheets
|
||||
- Batch 5 remaining rebel/bandit: 6 sheets
|
||||
- Batch 6 Nanman/special: 10 sheets
|
||||
- Batch 7 unique officers: 19 sheets
|
||||
|
||||
## Batch 2 Shu Order
|
||||
|
||||
The Shu infantry v3 sample is the quality gate before expanding the rest of batch 2.
|
||||
|
||||
1. `unit-shu-infantry` - pending approval as the v3 restart sample.
|
||||
2. `unit-shu-spearman` - long spear silhouette, green/bronze Shu armor. Spec: `unit-sprite-batch2-shu-spearman-spec.md`
|
||||
3. `unit-shu-archer` - bow silhouette readable at 50px. Spec: `unit-sprite-batch2-shu-archer-spec.md`
|
||||
4. `unit-shu-cavalry` - horse and rider must fit inside tile without lateral drift. Spec: `unit-sprite-batch2-shu-cavalry-spec.md`
|
||||
5. `unit-shu-strategist` - fan/scroll/robe silhouette. Spec: `unit-sprite-batch2-shu-strategist-spec.md`
|
||||
6. `unit-shu-officer` - command/officer silhouette. Spec: `unit-sprite-batch2-shu-officer-spec.md`
|
||||
7. Variants after primary classes are approved:
|
||||
- `unit-shu-infantry-banner`
|
||||
- `unit-shu-infantry-guard`
|
||||
- `unit-shu-spearman-guard`
|
||||
- `unit-shu-spearman-veteran`
|
||||
- `unit-shu-archer-longbow`
|
||||
- `unit-shu-archer-veteran`
|
||||
- `unit-shu-cavalry-scout`
|
||||
- `unit-shu-cavalry-white`
|
||||
- `unit-shu-strategist-field`
|
||||
- `unit-shu-strategist-white`
|
||||
- `unit-shu-officer-council`
|
||||
- `unit-shu-officer-vanguard`
|
||||
|
||||
## Quality Gate
|
||||
|
||||
Each unit must pass these checks before batch commit/deploy:
|
||||
|
||||
- New hand-painted raster source, not a filter-only or simple shape pass.
|
||||
- Existing 313x313 frame layout preserved.
|
||||
- Base sheet size: `5008x1252`.
|
||||
- Action sheet size: `11268x1252`.
|
||||
- Partial alpha: `0`.
|
||||
- Frame border hits: `0` unless a reviewed attack effect intentionally reaches near the border without gameplay clipping.
|
||||
- Idle/walk body scale and bottom position stable.
|
||||
- No detached sprite fragments in idle/walk frames.
|
||||
- 50px battle-scale view reads before the background.
|
||||
- Contact sheet, GIF, before/after, and battle-scale verification image generated.
|
||||
- `pnpm build` passes.
|
||||
- Local PC browser preview has no console errors.
|
||||
|
||||
## Approval Gate
|
||||
|
||||
- Do not expand to the next unit until the current sample direction is approved.
|
||||
- Do not commit, push, or deploy an unapproved sample batch.
|
||||
- Use `unit-sprite-review-approved-and-pending.png` for quality review of actual deliverables; batch boards still include old queued assets for inventory only.
|
||||
- Review stages are controlled by `unit-sprite-rework-manifest.json`, not by whether a sheet is merely technically `ok`.
|
||||
- When the user approves a unit or batch, move it from `pendingApproval` to `approvedBaseline` in the manifest before regenerating audit/review boards.
|
||||
- `scripts/check-unit-sprite-approval-gate.py --mode sample` must pass while a sample is pending.
|
||||
- `scripts/check-unit-sprite-art-quality.py` must pass for the current pending sample before asking for approval.
|
||||
- `scripts/check-unit-sprite-workflow-consistency.py` must pass so manifest, docs, and gate command lists stay aligned.
|
||||
- `scripts/check-unit-sprite-approval-gate.py --mode release` must pass before commit/push/deploy; it blocks pending samples by design.
|
||||
- `scripts/write-unit-sprite-next-steps.py` records the current blocker and the first queued unit after approval.
|
||||
- After a batch is approved, run build/browser verification, then commit, push, and deploy.
|
||||
84
docs/unit-sprite-shu-infantry-v8-approval-packet.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Shu Infantry v8 Approval Packet
|
||||
|
||||
This packet records the approved `unit-shu-infantry` Batch 2 Shu quality baseline.
|
||||
|
||||
## Decision Target
|
||||
|
||||
- Unit: `unit-shu-infantry`
|
||||
- Batch: Batch 2 Shu generic units
|
||||
- Status: approved by user
|
||||
- Review stage: `approved-baseline`
|
||||
- Review manifest: `unit-sprite-rework-manifest.json`
|
||||
- Art quality gate: `unit-sprite-art-quality-gate.md`
|
||||
- Next unit after approval: `unit-shu-spearman`
|
||||
- Commit/push/deploy: allowed after release gate verification
|
||||
|
||||
## Visual Evidence
|
||||
|
||||
- Approval board at review time: `unit-sprite-current-approval-board.png`
|
||||
- Source art: `handpaint-batch2-shu-infantry-quality-restart-source-v8.png`
|
||||
- Contact sheet: `handpaint-batch2-shu-infantry-quality-restart-v8-contact.png`
|
||||
- Animation GIF: `handpaint-batch2-shu-infantry-quality-restart-v8-animation.gif`
|
||||
- v7/v8 comparison: `handpaint-batch2-shu-infantry-quality-restart-v8-before-after.png`
|
||||
- 50px/72px scale comparison: `handpaint-batch2-shu-infantry-quality-restart-v8-scale-compare.png`
|
||||
- Battle-scale map composite: `handpaint-batch2-shu-infantry-quality-restart-v8-battle-composite.png`
|
||||
- Local browser title check: `handpaint-batch2-shu-infantry-quality-restart-v8-browser-check.png`
|
||||
- Local browser first battle check: `handpaint-batch2-shu-infantry-quality-restart-v8-battle-browser-check.png`
|
||||
- Local browser debug sprite preview: `handpaint-batch2-shu-infantry-quality-restart-v8-debug-preview-battle-check.png`
|
||||
- Approved/pending deliverables board: `unit-sprite-review-approved-and-pending.png`
|
||||
- Batch 2 inventory board: `unit-sprite-review-batch2-shu.png`
|
||||
- Review index: `unit-sprite-review-index.png`
|
||||
|
||||
## Technical Evidence
|
||||
|
||||
- Base sheet: `src/assets/images/units/unit-shu-infantry.png`
|
||||
- Action sheet: `src/assets/images/units/unit-shu-infantry-actions.png`
|
||||
- Base sheet size: `5008x1252`
|
||||
- Action sheet size: `11268x1252`
|
||||
- Base partial alpha: `0`
|
||||
- Action partial alpha: `0`
|
||||
- Base frame border hits: `0`
|
||||
- Action frame border hits: `0`
|
||||
- Base frame empty count: `0`
|
||||
- Action frame empty count: `0`
|
||||
- Base bbox range: `183..222 x 288..288`
|
||||
- Action bbox range: `181..255 x 280..288`
|
||||
- Base bottom range: `303..303`
|
||||
- Action bottom range: `303..303`
|
||||
- Audit status: `ok`
|
||||
|
||||
## Completed Verification
|
||||
|
||||
- `python scripts/assemble-handpaint-shu-infantry-quality-sample.py --source docs\handpaint-batch2-shu-infantry-quality-restart-source-v8.png --work-dir tmp\handpaint-shu-infantry-quality-sample-v8 --output-prefix handpaint-batch2-shu-infantry-quality-restart-v8`
|
||||
- Technical alpha/border/bbox inspection passed
|
||||
- `pnpm build`
|
||||
- Local browser app load passed at `http://127.0.0.1:4173/heros_web/?debug&debugSpritePreview=unit-shu-infantry&v=shu-infantry-v8-final`
|
||||
- Local browser first battle load passed at the same URL.
|
||||
- Local browser debug sprite preview passed at the same URL.
|
||||
- Local browser console errors: `0`
|
||||
- Note: the first battle uses approved baseline story units; `unit-shu-infantry` is verified in the real battle scene through the debug-only sprite preview panel.
|
||||
|
||||
## Quality Note
|
||||
|
||||
This v8 candidate replaced v7 and was approved as the current Shu infantry baseline. v7 was technically clean, but v8 raises the visible art bar with stronger hand-painted armor mass, a more readable gold-rim shield, clearer sword angles, denser helmet and shoulder detail, and more distinct pose-to-pose motion while preserving the 313px frame format and 50px battle readability.
|
||||
|
||||
Approved because:
|
||||
|
||||
- It reads clearly at the 50px battle size.
|
||||
- It feels close enough to the approved Liu Bei/Guan Yu/Zhang Fei/Rebel direction.
|
||||
- The helmet, large shield, sword, green Shu armor, and foot-soldier silhouette read before the background.
|
||||
- The unit does not look ghostly, transparent, low-resolution, too thin, or like a placeholder.
|
||||
- The user accepted this as the quality bar for continuing Batch 2.
|
||||
|
||||
After approval completed:
|
||||
|
||||
- `unit-shu-infantry` was moved from `pendingApproval` to `approvedBaseline` in `unit-sprite-rework-manifest.json`.
|
||||
- Audit, review boards, release gate, and build were rerun.
|
||||
- Next unit: `unit-shu-spearman`.
|
||||
|
||||
Reject or request revision if:
|
||||
|
||||
- The character still feels too generic, too stiff, or lower quality than the approved first batch.
|
||||
- The style is not close enough to the approved Liu Bei/Guan Yu/Zhang Fei/Rebel direction.
|
||||
- The 50px view is not readable enough for live battle play.
|
||||
- The armor/weapon silhouette should be more exaggerated before applying the style to the remaining Shu units.
|
||||
20
docs/unit-sprite-workflow-consistency.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Unit Sprite Workflow Consistency
|
||||
|
||||
This file is generated by `scripts/check-unit-sprite-workflow-consistency.py`.
|
||||
|
||||
The check validates that manifest state, approval docs, and gate command lists describe the same workflow.
|
||||
|
||||
## Result
|
||||
|
||||
OK
|
||||
|
||||
## Checked
|
||||
|
||||
- Manifest: `docs/unit-sprite-rework-manifest.json`
|
||||
- Audit: `docs/unit-sprite-rework-audit.json`
|
||||
- Next steps: `docs/unit-sprite-next-steps.md`
|
||||
- Pipeline guide: `docs/unit-sprite-handpaint-pipeline-guide.md`
|
||||
- Checklist: `docs/unit-sprite-batch-output-checklist.md`
|
||||
- Rework plan: `docs/unit-sprite-rework-plan.md`
|
||||
- Current board: skipped because there is no pending approval unit.
|
||||
- Art quality gate: `docs/unit-sprite-art-quality-gate.md`
|
||||
608
scripts/assemble-handpaint-shu-infantry-quality-sample.py
Normal file
@@ -0,0 +1,608 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "docs" / "handpaint-batch2-shu-infantry-quality-restart-source-v3.png"
|
||||
WORK_DIR = ROOT / "tmp" / "handpaint-shu-infantry-quality-sample"
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
BATTLE_REFERENCE = DOCS_DIR / "handpaint-batch1-liu-guan-size-match-battle.png"
|
||||
|
||||
STEM = "unit-shu-infantry"
|
||||
OUTPUT_PREFIX = "handpaint-batch2-shu-infantry-quality-restart-v3"
|
||||
CONTACT_TITLE = "Shu Infantry Quality Restart Sample v3"
|
||||
BEFORE_TITLE = "Shu Infantry Previous / Quality Restart Sample v3"
|
||||
BEFORE_SCALE_LABEL = "Previous / v3 at battle scale"
|
||||
REPORT_TITLE = "Shu Infantry Quality Restart Sample v3"
|
||||
SAMPLE_LABEL = "shu sample"
|
||||
COMPARE_STEMS = ["unit-liu-bei", "unit-guan-yu", "unit-zhang-fei", "unit-rebel", STEM]
|
||||
COMPARE_LABELS = ["liu-bei", "guan-yu", "zhang-fei", "rebel", "shu-infantry"]
|
||||
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 remove_magenta(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 > 200) & (b > 200) & (g < 115)
|
||||
near_key = (r > 145) & (b > 135) & (g < 140) & ((r + b - g * 2) > 145)
|
||||
alpha = np.where(key | near_key, 0, 255).astype(np.uint8)
|
||||
|
||||
fringe = (alpha > 0) & (r > 150) & (b > 150) & (g < 140)
|
||||
arr[:, :, 0] = np.where(fringe, np.minimum(arr[:, :, 0], 90), arr[:, :, 0])
|
||||
arr[:, :, 2] = np.where(fringe, np.minimum(arr[:, :, 2], 90), arr[:, :, 2])
|
||||
arr[:, :, 3] = alpha
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def keep_useful_components(image: Image.Image, allow_detached_effects = False) -> 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
|
||||
|
||||
anchor = max(components, key=lambda item: item[0])
|
||||
anchor_area, left, top, right, bottom, anchor_cx, anchor_cy = anchor
|
||||
expanded = (left - 95, top - 90, right + 95, bottom + 82)
|
||||
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]
|
||||
big_effect = area >= max(550, anchor_area * 0.07)
|
||||
useful = component == anchor or (near_anchor and area >= 55) or (allow_detached_effects and big_effect)
|
||||
if useful:
|
||||
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 keep_largest_component(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)
|
||||
best_mask: np.ndarray | None = None
|
||||
best_area = 0
|
||||
|
||||
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
|
||||
mask = np.zeros_like(alpha, dtype=bool)
|
||||
area = 0
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
mask[y, x] = True
|
||||
area += 1
|
||||
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))
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best_mask = mask
|
||||
|
||||
if best_mask is None:
|
||||
return image
|
||||
arr[:, :, 3] = np.where(best_mask, arr[:, :, 3], 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def crop_cell(source: Image.Image, row: int, col: int) -> Image.Image:
|
||||
width, height = source.size
|
||||
left = round(col * width / 6)
|
||||
right = round((col + 1) * width / 6)
|
||||
top = round(row * height / 2)
|
||||
bottom = round((row + 1) * height / 2)
|
||||
pad_x = 0
|
||||
pad_y = 6
|
||||
return source.crop((max(0, left - pad_x), max(0, top - pad_y), min(width, right + pad_x), min(height, bottom + pad_y)))
|
||||
|
||||
|
||||
def crop_subject(image: Image.Image, allow_detached_effects = False) -> Image.Image:
|
||||
rgba = keep_useful_components(remove_magenta(image), allow_detached_effects)
|
||||
return trim_to_alpha(rgba, pad=7)
|
||||
|
||||
|
||||
def trim_to_alpha(image: Image.Image, pad: int = 0) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
alpha = np.array(rgba.getchannel("A"))
|
||||
ys, xs = np.where(alpha > 0)
|
||||
if len(xs) == 0:
|
||||
return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||
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 solidify_alpha(rgba.crop((left, top, right, bottom)))
|
||||
|
||||
|
||||
def solidify_alpha(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
arr = np.array(rgba)
|
||||
arr[:, :, 3] = np.where(arr[:, :, 3] > 20, 255, 0).astype(np.uint8)
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
def fit_frame(source: Image.Image, max_width: int, max_height: int, bottom: int = 304, x_offset: int = 0, allow_detached_effects = False) -> Image.Image:
|
||||
subject = crop_subject(source, allow_detached_effects)
|
||||
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 = solidify_alpha(subject.resize(size, Image.Resampling.LANCZOS))
|
||||
subject = trim_to_alpha(subject)
|
||||
if not allow_detached_effects:
|
||||
subject = keep_largest_component(subject)
|
||||
subject = trim_to_alpha(subject)
|
||||
frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0))
|
||||
x = (FRAME - subject.width) // 2 + x_offset
|
||||
y = bottom - subject.height
|
||||
frame.alpha_composite(subject, (x, y))
|
||||
return solidify_alpha(frame)
|
||||
|
||||
|
||||
def mirror(frame: Image.Image) -> Image.Image:
|
||||
return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
|
||||
|
||||
def load_pose_frames() -> dict[str, Image.Image]:
|
||||
source = Image.open(SOURCE).convert("RGB")
|
||||
cells = {(row, col): crop_cell(source, row, col) for row in range(2) for col in range(6)}
|
||||
return {
|
||||
"idle_front": fit_frame(cells[(0, 0)], 252, 300),
|
||||
"walk_front": fit_frame(cells[(0, 1)], 252, 300),
|
||||
"walk_side": fit_frame(cells[(0, 2)], 266, 300),
|
||||
"walk_back": fit_frame(cells[(0, 3)], 254, 300),
|
||||
"ready_side": fit_frame(cells[(0, 4)], 266, 300),
|
||||
"attack_ready": fit_frame(cells[(0, 5)], 284, 294),
|
||||
"attack_slash": fit_frame(cells[(1, 0)], 300, 294, allow_detached_effects=True),
|
||||
"attack_thrust": fit_frame(cells[(1, 1)], 292, 294),
|
||||
"command": keep_largest_component(fit_frame(cells[(1, 2)], 264, 300)),
|
||||
"hurt": fit_frame(cells[(1, 3)], 252, 294),
|
||||
"victory": keep_largest_component(fit_frame(cells[(1, 4)], 264, 300)),
|
||||
"walk_side_alt": fit_frame(cells[(1, 5)], 266, 300),
|
||||
}
|
||||
|
||||
|
||||
def build_base_sheet(poses: dict[str, Image.Image]) -> Image.Image:
|
||||
rows = {
|
||||
"south": {
|
||||
"idle": [poses["idle_front"]] * 8,
|
||||
"walk": [poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"]],
|
||||
},
|
||||
"east": {
|
||||
"idle": [poses["ready_side"]] * 8,
|
||||
"walk": [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]],
|
||||
},
|
||||
"north": {
|
||||
"idle": [poses["walk_back"]] * 8,
|
||||
"walk": [poses["walk_back"]] * 8,
|
||||
},
|
||||
"west": {
|
||||
"idle": [mirror(poses["ready_side"])] * 8,
|
||||
"walk": [mirror(frame) for frame in [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]]],
|
||||
},
|
||||
}
|
||||
sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0))
|
||||
for row, direction in enumerate(DIRECTIONS):
|
||||
frames = rows[direction]["idle"] + rows[direction]["walk"]
|
||||
for col, frame in enumerate(frames):
|
||||
sheet.alpha_composite(solidify_alpha(frame), (col * FRAME, row * FRAME))
|
||||
return solidify_alpha(sheet)
|
||||
|
||||
|
||||
def directed(frame: Image.Image, direction: str) -> Image.Image:
|
||||
if direction == "west":
|
||||
return mirror(frame)
|
||||
return frame
|
||||
|
||||
|
||||
def build_action_sheet(poses: dict[str, Image.Image]) -> Image.Image:
|
||||
attack = [
|
||||
poses["attack_ready"],
|
||||
poses["attack_ready"],
|
||||
poses["attack_thrust"],
|
||||
poses["attack_slash"],
|
||||
poses["attack_slash"],
|
||||
poses["attack_thrust"],
|
||||
poses["ready_side"],
|
||||
poses["walk_side_alt"],
|
||||
poses["ready_side"],
|
||||
poses["ready_side"],
|
||||
]
|
||||
strategy = [poses["command"], poses["command"], poses["victory"], poses["command"], poses["command"], poses["victory"], poses["command"], poses["idle_front"]]
|
||||
item = [poses["idle_front"], poses["command"], poses["victory"], poses["command"], poses["idle_front"], poses["command"], poses["victory"], poses["idle_front"]]
|
||||
hurt = [poses["hurt"]] * 4
|
||||
celebrate = [poses["victory"], poses["command"], poses["victory"], poses["command"], poses["victory"], poses["idle_front"]]
|
||||
groups = {
|
||||
"attack": attack,
|
||||
"strategy": strategy,
|
||||
"item": item,
|
||||
"hurt": hurt,
|
||||
"celebrate": celebrate,
|
||||
}
|
||||
|
||||
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 groups.items():
|
||||
offset = ACTION_OFFSETS[action]
|
||||
for index, frame in enumerate(frames):
|
||||
action_frame = directed(frame, direction)
|
||||
if direction == "south" and action == "attack":
|
||||
action_frame = frame
|
||||
if direction == "north" and action in {"attack", "strategy", "item", "celebrate"}:
|
||||
action_frame = poses["walk_back"] if action == "attack" else poses["command"]
|
||||
sheet.alpha_composite(solidify_alpha(action_frame), ((offset + index) * FRAME, row * FRAME))
|
||||
return solidify_alpha(sheet)
|
||||
|
||||
|
||||
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 font(size: int = 12):
|
||||
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image:
|
||||
bg = checker_background((size, size), 8)
|
||||
scaled = solidify_alpha(frame).resize((size, size), Image.Resampling.LANCZOS)
|
||||
bg.alpha_composite(scaled, (0, 0))
|
||||
return bg
|
||||
|
||||
|
||||
def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image:
|
||||
return sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME))
|
||||
|
||||
|
||||
def save_contact_sheet(base_sheet: Image.Image, action_sheet: Image.Image) -> Path:
|
||||
picks = [
|
||||
("idle", crop_frame(base_sheet, 0, 0)),
|
||||
("walk S", crop_frame(base_sheet, 0, 8)),
|
||||
("walk E", crop_frame(base_sheet, 1, 8)),
|
||||
("walk N", crop_frame(base_sheet, 2, 8)),
|
||||
("walk W", crop_frame(base_sheet, 3, 8)),
|
||||
("atk 1", crop_frame(action_sheet, 1, 0)),
|
||||
("atk 4", crop_frame(action_sheet, 1, 3)),
|
||||
("atk 7", crop_frame(action_sheet, 1, 6)),
|
||||
("cmd", crop_frame(action_sheet, 0, ACTION_OFFSETS["strategy"] + 2)),
|
||||
("item", crop_frame(action_sheet, 0, ACTION_OFFSETS["item"] + 1)),
|
||||
("hurt", crop_frame(action_sheet, 0, ACTION_OFFSETS["hurt"])),
|
||||
("win", crop_frame(action_sheet, 0, ACTION_OFFSETS["celebrate"] + 1)),
|
||||
]
|
||||
thumb = 132
|
||||
pad = 18
|
||||
label_h = 24
|
||||
cols = 6
|
||||
rows = 2
|
||||
out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad) + 18), (20, 24, 22, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
draw.text((pad, 5), CONTACT_TITLE, fill=(240, 226, 178, 255), font=font(14))
|
||||
for index, (label, frame) in enumerate(picks):
|
||||
x = pad + (index % cols) * (thumb + pad)
|
||||
y = 24 + pad + (index // cols) * (thumb + label_h + pad)
|
||||
out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y))
|
||||
draw.text((x, y + thumb + 5), label, fill=(238, 224, 178, 255), font=font(11))
|
||||
path = DOCS_DIR / f"{OUTPUT_PREFIX}-contact.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_before_after(base_sheet: Image.Image) -> Path:
|
||||
before = Image.open(WORK_DIR / f"before-{STEM}.png").convert("RGBA").crop((0, 0, FRAME, FRAME))
|
||||
after = crop_frame(base_sheet, 0, 0)
|
||||
out = Image.new("RGBA", (820, 360), (18, 21, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
draw.text((30, 18), BEFORE_TITLE, fill=(238, 224, 178, 255), font=font(14))
|
||||
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_SCALE_LABEL, fill=(238, 224, 178, 255), font=font(12))
|
||||
out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252))
|
||||
out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252))
|
||||
path = DOCS_DIR / f"{OUTPUT_PREFIX}-before-after.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_animation_gif(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 = crop_frame(base_sheet, 0, index % 8)
|
||||
walk = crop_frame(base_sheet, 1, 8 + index % 8)
|
||||
attack = crop_frame(action_sheet, 1, index)
|
||||
for x, frame in ((22, idle), (190, walk), (358, attack)):
|
||||
canvas.alpha_composite(frame.resize((150, 150), Image.Resampling.LANCZOS), (x, 25))
|
||||
frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=160))
|
||||
path = DOCS_DIR / f"{OUTPUT_PREFIX}-animation.gif"
|
||||
frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=120, loop=0)
|
||||
return path
|
||||
|
||||
|
||||
def save_scale_compare() -> Path:
|
||||
stems = COMPARE_STEMS
|
||||
labels = COMPARE_LABELS
|
||||
tile = 50
|
||||
zoom = 72
|
||||
gap = 22
|
||||
width = 28 + len(stems) * (zoom + gap)
|
||||
height = 210
|
||||
out = Image.new("RGBA", (width, height), (19, 25, 21, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
draw.text((18, 10), "Battle-scale readability compare", fill=(240, 226, 178, 255), font=font(14))
|
||||
for index, stem in enumerate(stems):
|
||||
sheet = Image.open(UNIT_DIR / f"{stem}.png").convert("RGBA")
|
||||
frame = crop_frame(sheet, 0, 0)
|
||||
x = 18 + index * (zoom + gap)
|
||||
out.alpha_composite(draw_frame_on_bg(frame, zoom), (x, 38))
|
||||
out.alpha_composite(draw_frame_on_bg(frame, tile), (x + 11, 122))
|
||||
draw.text((x, 180), labels[index], fill=(226, 218, 175, 255), font=font(10))
|
||||
path = DOCS_DIR / f"{OUTPUT_PREFIX}-scale-compare.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_battle_composite(base_sheet: Image.Image) -> Path:
|
||||
battle = Image.open(BATTLE_REFERENCE).convert("RGBA")
|
||||
frame = crop_frame(base_sheet, 0, 0)
|
||||
draw = ImageDraw.Draw(battle)
|
||||
display = 72
|
||||
small = 50
|
||||
|
||||
# Empty-ish field cells near the approved ally sprites. This is a review
|
||||
# composite only; the gameplay roster is left untouched during sample review.
|
||||
battle.alpha_composite(solidify_alpha(frame).resize((display, display), Image.Resampling.LANCZOS), (207, 535))
|
||||
draw.text((207, 612), SAMPLE_LABEL, fill=(255, 248, 218, 255), font=font(12), stroke_width=2, stroke_fill=(20, 18, 16, 230))
|
||||
|
||||
panel_x, panel_y = 936, 542
|
||||
draw.rounded_rectangle((panel_x, panel_y, panel_x + 205, panel_y + 116), radius=4, fill=(17, 23, 31, 235), outline=(214, 181, 99, 220), width=2)
|
||||
draw.text((panel_x + 14, panel_y + 12), "Composite scale check", fill=(240, 226, 178, 255), font=font(13))
|
||||
draw.text((panel_x + 14, panel_y + 32), "not gameplay", fill=(198, 204, 200, 255), font=font(11))
|
||||
battle.alpha_composite(draw_frame_on_bg(frame, display), (panel_x + 18, panel_y + 56))
|
||||
battle.alpha_composite(draw_frame_on_bg(frame, small), (panel_x + 114, panel_y + 67))
|
||||
draw.text((panel_x + 20, panel_y + 132), "72px", fill=(238, 224, 178, 255), font=font(10))
|
||||
draw.text((panel_x + 117, panel_y + 132), "50px", fill=(238, 224, 178, 255), font=font(10))
|
||||
|
||||
path = DOCS_DIR / f"{OUTPUT_PREFIX}-battle-composite.png"
|
||||
battle.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def validate(path: Path, expected: tuple[int, int], rows: int, cols: int) -> dict[str, int | str]:
|
||||
image = Image.open(path).convert("RGBA")
|
||||
if image.size != expected:
|
||||
raise ValueError(f"{path}: expected {expected}, 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}: partial alpha {partial}")
|
||||
|
||||
widths: list[int] = []
|
||||
heights: list[int] = []
|
||||
bottoms: list[int] = []
|
||||
border_hits = 0
|
||||
empty_frames = 0
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME]
|
||||
ys, xs = np.where(frame > 0)
|
||||
if len(xs) == 0:
|
||||
empty_frames += 1
|
||||
continue
|
||||
widths.append(int(xs.max() - xs.min() + 1))
|
||||
heights.append(int(ys.max() - ys.min() + 1))
|
||||
bottoms.append(int(ys.max()))
|
||||
if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2:
|
||||
border_hits += 1
|
||||
|
||||
return {
|
||||
"partial_alpha": partial,
|
||||
"opaque_pixels": opaque,
|
||||
"border_hits": border_hits,
|
||||
"empty_frames": empty_frames,
|
||||
"min_width": min(widths) if widths else 0,
|
||||
"max_width": max(widths) if widths else 0,
|
||||
"min_height": min(heights) if heights else 0,
|
||||
"max_height": max(heights) if heights else 0,
|
||||
"min_bottom": min(bottoms) if bottoms else 0,
|
||||
"max_bottom": max(bottoms) if bottoms else 0,
|
||||
"status": "ok" if border_hits == 0 and empty_frames == 0 else "needs-review",
|
||||
}
|
||||
|
||||
|
||||
def copy_before_once() -> None:
|
||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for suffix in ("", "-actions"):
|
||||
source = UNIT_DIR / f"{STEM}{suffix}.png"
|
||||
target = WORK_DIR / f"before-{STEM}{suffix}.png"
|
||||
if not target.exists():
|
||||
target.write_bytes(source.read_bytes())
|
||||
|
||||
|
||||
def project_path(value: str) -> Path:
|
||||
path = Path(value)
|
||||
return path if path.is_absolute() else ROOT / path
|
||||
|
||||
|
||||
def split_csv(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Assemble a 6x2 hand-painted source into 313px unit sprite sheets.")
|
||||
parser.add_argument("--source", default=str(SOURCE.relative_to(ROOT)))
|
||||
parser.add_argument("--stem", default=STEM)
|
||||
parser.add_argument("--work-dir", default=str(WORK_DIR.relative_to(ROOT)))
|
||||
parser.add_argument("--output-prefix", default=OUTPUT_PREFIX)
|
||||
parser.add_argument("--battle-reference", default=str(BATTLE_REFERENCE.relative_to(ROOT)))
|
||||
parser.add_argument("--contact-title", default=CONTACT_TITLE)
|
||||
parser.add_argument("--before-title", default=BEFORE_TITLE)
|
||||
parser.add_argument("--before-scale-label", default=BEFORE_SCALE_LABEL)
|
||||
parser.add_argument("--report-title", default=REPORT_TITLE)
|
||||
parser.add_argument("--sample-label", default=SAMPLE_LABEL)
|
||||
parser.add_argument("--compare-stems", default=",".join(COMPARE_STEMS))
|
||||
parser.add_argument("--compare-labels", default=",".join(COMPARE_LABELS))
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def configure(args: argparse.Namespace) -> None:
|
||||
global SOURCE, WORK_DIR, BATTLE_REFERENCE, STEM, OUTPUT_PREFIX
|
||||
global CONTACT_TITLE, BEFORE_TITLE, BEFORE_SCALE_LABEL, REPORT_TITLE, SAMPLE_LABEL
|
||||
global COMPARE_STEMS, COMPARE_LABELS
|
||||
|
||||
SOURCE = project_path(args.source)
|
||||
WORK_DIR = project_path(args.work_dir)
|
||||
BATTLE_REFERENCE = project_path(args.battle_reference)
|
||||
STEM = args.stem
|
||||
OUTPUT_PREFIX = args.output_prefix
|
||||
CONTACT_TITLE = args.contact_title
|
||||
BEFORE_TITLE = args.before_title
|
||||
BEFORE_SCALE_LABEL = args.before_scale_label
|
||||
REPORT_TITLE = args.report_title
|
||||
SAMPLE_LABEL = args.sample_label
|
||||
COMPARE_STEMS = split_csv(args.compare_stems)
|
||||
COMPARE_LABELS = split_csv(args.compare_labels)
|
||||
if len(COMPARE_STEMS) != len(COMPARE_LABELS):
|
||||
raise ValueError("--compare-stems and --compare-labels must have the same number of entries")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
configure(parse_args())
|
||||
copy_before_once()
|
||||
poses = load_pose_frames()
|
||||
base_sheet = build_base_sheet(poses)
|
||||
action_sheet = build_action_sheet(poses)
|
||||
|
||||
base_path = UNIT_DIR / f"{STEM}.png"
|
||||
action_path = UNIT_DIR / f"{STEM}-actions.png"
|
||||
base_sheet.save(base_path, optimize=True)
|
||||
action_sheet.save(action_path, optimize=True)
|
||||
|
||||
contact = save_contact_sheet(base_sheet, action_sheet)
|
||||
before_after = save_before_after(base_sheet)
|
||||
animation = save_animation_gif(base_sheet, action_sheet)
|
||||
scale_compare = save_scale_compare()
|
||||
battle_composite = save_battle_composite(base_sheet)
|
||||
base_result = validate(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), BASE_FRAMES_PER_DIRECTION)
|
||||
action_result = validate(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), ACTION_FRAMES_PER_DIRECTION)
|
||||
|
||||
report = DOCS_DIR / f"{OUTPUT_PREFIX}-report.md"
|
||||
report.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"# {REPORT_TITLE}",
|
||||
"",
|
||||
f"- source: `{SOURCE.name}`",
|
||||
f"- base status: {base_result['status']}",
|
||||
f"- action status: {action_result['status']}",
|
||||
f"- base partial alpha: {base_result['partial_alpha']}, opaque pixels: {base_result['opaque_pixels']}",
|
||||
f"- action partial alpha: {action_result['partial_alpha']}, opaque pixels: {action_result['opaque_pixels']}",
|
||||
f"- base frame border hits: {base_result['border_hits']}",
|
||||
f"- action frame border hits: {action_result['border_hits']}",
|
||||
f"- base empty frames: {base_result['empty_frames']}",
|
||||
f"- action empty frames: {action_result['empty_frames']}",
|
||||
f"- base bbox width range: {base_result['min_width']}..{base_result['max_width']}",
|
||||
f"- base bbox height range: {base_result['min_height']}..{base_result['max_height']}",
|
||||
f"- base bottom range: {base_result['min_bottom']}..{base_result['max_bottom']}",
|
||||
f"- action bbox width range: {action_result['min_width']}..{action_result['max_width']}",
|
||||
f"- action bbox height range: {action_result['min_height']}..{action_result['max_height']}",
|
||||
f"- action bottom range: {action_result['min_bottom']}..{action_result['max_bottom']}",
|
||||
f"- contact: `{contact.name}`",
|
||||
f"- animation: `{animation.name}`",
|
||||
f"- before/after: `{before_after.name}`",
|
||||
f"- scale compare: `{scale_compare.name}`",
|
||||
f"- battle composite: `{battle_composite.name}`",
|
||||
"- manual checks: run build and local browser verification after generation",
|
||||
"- note: battle composite is a scale review image; gameplay roster is unchanged during sample approval",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {base_path}")
|
||||
print(f"Wrote {action_path}")
|
||||
print(f"Wrote {contact}")
|
||||
print(f"Wrote {animation}")
|
||||
print(f"Wrote {before_after}")
|
||||
print(f"Wrote {scale_compare}")
|
||||
print(f"Wrote {battle_composite}")
|
||||
print(f"Wrote {report}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
306
scripts/audit-unit-sprite-sheets.py
Normal file
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
FRAME = 313
|
||||
BASE_ROWS = 4
|
||||
BASE_COLS = 16
|
||||
ACTION_ROWS = 4
|
||||
ACTION_COLS = 36
|
||||
BATTLE_TILE_PX = 50
|
||||
|
||||
DEFAULT_MANIFEST = {
|
||||
"approvedBaseline": [
|
||||
"unit-liu-bei",
|
||||
"unit-guan-yu",
|
||||
"unit-zhang-fei",
|
||||
"unit-rebel",
|
||||
"unit-rebel-archer",
|
||||
"unit-rebel-cavalry",
|
||||
],
|
||||
"pendingApproval": ["unit-shu-infantry"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SheetMetrics:
|
||||
key: str
|
||||
exists: bool
|
||||
size: tuple[int, int] | None = None
|
||||
partial_alpha: int | None = None
|
||||
opaque_pixels: int | None = None
|
||||
border_hits: int | None = None
|
||||
min_width: int | None = None
|
||||
max_width: int | None = None
|
||||
min_height: int | None = None
|
||||
max_height: int | None = None
|
||||
min_bottom: int | None = None
|
||||
max_bottom: int | None = None
|
||||
empty_frames: int | None = None
|
||||
width_swing: int | None = None
|
||||
height_swing: int | None = None
|
||||
bottom_swing: int | None = None
|
||||
battle_min_width_px: float | None = None
|
||||
battle_max_width_px: float | None = None
|
||||
battle_min_height_px: float | None = None
|
||||
battle_max_height_px: float | None = None
|
||||
status: str = "missing"
|
||||
|
||||
|
||||
def measure_sheet(path: Path, key: str, rows: int, cols: int, check_motion_stability = False) -> SheetMetrics:
|
||||
if not path.exists():
|
||||
return SheetMetrics(key=key, exists=False)
|
||||
|
||||
image = Image.open(path).convert("RGBA")
|
||||
alpha = np.array(image.getchannel("A"))
|
||||
metrics = SheetMetrics(
|
||||
key=key,
|
||||
exists=True,
|
||||
size=image.size,
|
||||
partial_alpha=int(np.count_nonzero((alpha > 0) & (alpha < 255))),
|
||||
opaque_pixels=int(np.count_nonzero(alpha == 255)),
|
||||
border_hits=0,
|
||||
empty_frames=0,
|
||||
status="ok",
|
||||
)
|
||||
|
||||
expected_size = (FRAME * cols, FRAME * rows)
|
||||
widths: list[int] = []
|
||||
heights: list[int] = []
|
||||
bottoms: list[int] = []
|
||||
if image.size != expected_size:
|
||||
metrics.status = f"bad-size expected={expected_size}"
|
||||
return metrics
|
||||
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME]
|
||||
ys, xs = np.where(frame > 0)
|
||||
if len(xs) == 0:
|
||||
metrics.empty_frames = (metrics.empty_frames or 0) + 1
|
||||
continue
|
||||
|
||||
widths.append(int(xs.max() - xs.min() + 1))
|
||||
heights.append(int(ys.max() - ys.min() + 1))
|
||||
bottoms.append(int(ys.max()))
|
||||
if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2:
|
||||
metrics.border_hits = (metrics.border_hits or 0) + 1
|
||||
|
||||
if widths:
|
||||
metrics.min_width = min(widths)
|
||||
metrics.max_width = max(widths)
|
||||
metrics.min_height = min(heights)
|
||||
metrics.max_height = max(heights)
|
||||
metrics.min_bottom = min(bottoms)
|
||||
metrics.max_bottom = max(bottoms)
|
||||
metrics.width_swing = metrics.max_width - metrics.min_width
|
||||
metrics.height_swing = metrics.max_height - metrics.min_height
|
||||
metrics.bottom_swing = metrics.max_bottom - metrics.min_bottom
|
||||
metrics.battle_min_width_px = round(metrics.min_width * BATTLE_TILE_PX / FRAME, 1)
|
||||
metrics.battle_max_width_px = round(metrics.max_width * BATTLE_TILE_PX / FRAME, 1)
|
||||
metrics.battle_min_height_px = round(metrics.min_height * BATTLE_TILE_PX / FRAME, 1)
|
||||
metrics.battle_max_height_px = round(metrics.max_height * BATTLE_TILE_PX / FRAME, 1)
|
||||
|
||||
issues = []
|
||||
if metrics.partial_alpha:
|
||||
issues.append(f"partial-alpha={metrics.partial_alpha}")
|
||||
if metrics.border_hits:
|
||||
issues.append(f"border-hits={metrics.border_hits}")
|
||||
if metrics.empty_frames:
|
||||
issues.append(f"empty-frames={metrics.empty_frames}")
|
||||
if (
|
||||
check_motion_stability
|
||||
and metrics.max_height is not None
|
||||
and metrics.min_height is not None
|
||||
and metrics.max_height - metrics.min_height > 48
|
||||
):
|
||||
issues.append(f"height-swing={metrics.max_height - metrics.min_height}")
|
||||
if (
|
||||
check_motion_stability
|
||||
and metrics.max_bottom is not None
|
||||
and metrics.min_bottom is not None
|
||||
and metrics.max_bottom - metrics.min_bottom > 36
|
||||
):
|
||||
issues.append(f"bottom-swing={metrics.max_bottom - metrics.min_bottom}")
|
||||
metrics.status = "ok" if not issues else "; ".join(issues)
|
||||
return metrics
|
||||
|
||||
|
||||
def classify(stem: str) -> str:
|
||||
if stem in {"unit-liu-bei", "unit-guan-yu", "unit-zhang-fei"}:
|
||||
return "batch1-approved-core"
|
||||
if stem in {"unit-rebel", "unit-rebel-archer", "unit-rebel-cavalry"}:
|
||||
return "batch1-approved-rebel"
|
||||
if stem.startswith("unit-shu-"):
|
||||
return "batch2-shu"
|
||||
if stem.startswith("unit-wei-"):
|
||||
return "batch3-wei"
|
||||
if stem.startswith("unit-wu-"):
|
||||
return "batch4-wu"
|
||||
if stem.startswith("unit-rebel-"):
|
||||
return "batch5-rebel-other"
|
||||
if stem.startswith("unit-nanman-") or stem == "unit-meng-huo":
|
||||
return "batch6-nanman-special"
|
||||
return "batch7-unique-officer"
|
||||
|
||||
|
||||
def unit_stems() -> list[str]:
|
||||
return sorted(path.stem for path in UNIT_DIR.glob("unit-*.png") if not path.stem.endswith("-actions"))
|
||||
|
||||
|
||||
def load_manifest(path: Path = MANIFEST_PATH) -> dict:
|
||||
if not path.exists():
|
||||
return DEFAULT_MANIFEST
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
**DEFAULT_MANIFEST,
|
||||
**manifest,
|
||||
}
|
||||
|
||||
|
||||
def review_stage(stem: str, manifest: dict) -> str:
|
||||
if stem in set(manifest.get("approvedBaseline", [])):
|
||||
return "approved-baseline"
|
||||
if stem in set(manifest.get("pendingApproval", [])):
|
||||
return "pending-approval"
|
||||
return "queued-rework"
|
||||
|
||||
|
||||
def audit() -> tuple[list[dict], dict[str, int], dict[str, int]]:
|
||||
rows: list[dict] = []
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
stage_counts: dict[str, int] = defaultdict(int)
|
||||
manifest = load_manifest()
|
||||
for stem in unit_stems():
|
||||
base = measure_sheet(UNIT_DIR / f"{stem}.png", stem, BASE_ROWS, BASE_COLS, check_motion_stability=True)
|
||||
actions = measure_sheet(UNIT_DIR / f"{stem}-actions.png", f"{stem}-actions", ACTION_ROWS, ACTION_COLS)
|
||||
batch = classify(stem)
|
||||
stage = review_stage(stem, manifest)
|
||||
counts[batch] += 1
|
||||
stage_counts[stage] += 1
|
||||
rows.append(
|
||||
{
|
||||
"batch": batch,
|
||||
"review_stage": stage,
|
||||
"stem": stem,
|
||||
"base": asdict(base),
|
||||
"actions": asdict(actions),
|
||||
}
|
||||
)
|
||||
return rows, dict(sorted(counts.items())), dict(sorted(stage_counts.items()))
|
||||
|
||||
|
||||
def write_markdown(rows: list[dict], counts: dict[str, int], stage_counts: dict[str, int], path: Path) -> None:
|
||||
lines = [
|
||||
"# Unit Sprite Rework Audit",
|
||||
"",
|
||||
"This file is generated by `scripts/audit-unit-sprite-sheets.py`.",
|
||||
"",
|
||||
f"Review stages are loaded from `{MANIFEST_PATH.relative_to(ROOT).as_posix()}`.",
|
||||
"",
|
||||
"Visual review boards are generated by `scripts/render-unit-sprite-review-boards.py`.",
|
||||
"",
|
||||
"- Review index: `unit-sprite-review-index.png`",
|
||||
"- Batch 1 core: `unit-sprite-review-batch1-approved-core.png`",
|
||||
"- Batch 1 rebel: `unit-sprite-review-batch1-approved-rebel.png`",
|
||||
"- Batch 2 Shu: `unit-sprite-review-batch2-shu.png`",
|
||||
"- Batch 3 Wei: `unit-sprite-review-batch3-wei.png`",
|
||||
"- Batch 4 Wu: `unit-sprite-review-batch4-wu.png`",
|
||||
"- Batch 5 rebel other: `unit-sprite-review-batch5-rebel-other.png`",
|
||||
"- Batch 6 Nanman/special: `unit-sprite-review-batch6-nanman-special.png`",
|
||||
"- Batch 7 unique officer: `unit-sprite-review-batch7-unique-officer.png`",
|
||||
"",
|
||||
"## Batch Counts",
|
||||
"",
|
||||
]
|
||||
for batch, count in counts.items():
|
||||
lines.append(f"- {batch}: {count}")
|
||||
|
||||
lines.extend(["", "## Review Stage Counts", ""])
|
||||
for stage, count in stage_counts.items():
|
||||
lines.append(f"- {stage}: {count}")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Batch Plan",
|
||||
"",
|
||||
"- batch1-approved-core/rebel: already approved baseline, use as quality reference.",
|
||||
"- batch2-shu: Shu generic infantry/spearman/archer/cavalry/strategist/officer variants.",
|
||||
"- batch3-wei: Wei generic infantry/archer/cavalry/strategist/officer variants.",
|
||||
"- batch4-wu: Wu generic infantry/archer/cavalry/strategist/officer variants.",
|
||||
"- batch5-rebel-other: remaining rebel/bandit/yellow-turban variants outside approved batch1.",
|
||||
"- batch6-nanman-special: Nanman and special tribal unit variants.",
|
||||
"- batch7-unique-officer: unique officer sheets not covered by earlier batches.",
|
||||
"",
|
||||
"## Review Stage",
|
||||
"",
|
||||
"- `approved-baseline`: approved hand-painted reference units.",
|
||||
"- `pending-approval`: technically prepared, waiting for user approval before expansion/commit/deploy.",
|
||||
"- `queued-rework`: listed for later hand-painted rework; technical `ok` does not mean visually approved.",
|
||||
"",
|
||||
"## Sheet Status",
|
||||
"",
|
||||
"| batch | stage | stem | base | actions | base bbox | action bbox | battle base px | motion swing |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for row in rows:
|
||||
base = row["base"]
|
||||
actions = row["actions"]
|
||||
base_bbox = f'{base.get("min_width")}..{base.get("max_width")} x {base.get("min_height")}..{base.get("max_height")}'
|
||||
action_bbox = f'{actions.get("min_width")}..{actions.get("max_width")} x {actions.get("min_height")}..{actions.get("max_height")}'
|
||||
battle_base = (
|
||||
f'{base.get("battle_min_width_px")}..{base.get("battle_max_width_px")} x '
|
||||
f'{base.get("battle_min_height_px")}..{base.get("battle_max_height_px")}'
|
||||
)
|
||||
motion = (
|
||||
f'w{base.get("width_swing")} '
|
||||
f'h{base.get("height_swing")} '
|
||||
f'b{base.get("bottom_swing")}'
|
||||
)
|
||||
lines.append(
|
||||
f'| {row["batch"]} | {row["review_stage"]} | `{row["stem"]}` | {base["status"]} | {actions["status"]} | {base_bbox} | {action_bbox} | {battle_base} | {motion} |'
|
||||
)
|
||||
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Audit 313px unit sprite sheets.")
|
||||
parser.add_argument("--json", type=Path, default=DOCS_DIR / "unit-sprite-rework-audit.json")
|
||||
parser.add_argument("--markdown", type=Path, default=DOCS_DIR / "unit-sprite-rework-audit.md")
|
||||
args = parser.parse_args()
|
||||
|
||||
rows, counts, stage_counts = audit()
|
||||
args.json.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"manifest": MANIFEST_PATH.relative_to(ROOT).as_posix(),
|
||||
"counts": counts,
|
||||
"stage_counts": stage_counts,
|
||||
"rows": rows,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_markdown(rows, counts, stage_counts, args.markdown)
|
||||
print(f"Wrote {args.json}")
|
||||
print(f"Wrote {args.markdown}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
141
scripts/check-unit-sprite-approval-gate.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def sorted_unique(values: list[str]) -> list[str]:
|
||||
return sorted(dict.fromkeys(values))
|
||||
|
||||
|
||||
def row_by_stem(audit: dict) -> dict[str, dict]:
|
||||
return {row["stem"]: row for row in audit.get("rows", [])}
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict, audit: dict) -> list[str]:
|
||||
issues: list[str] = []
|
||||
rows = row_by_stem(audit)
|
||||
approved = sorted_unique(manifest.get("approvedBaseline", []))
|
||||
pending = sorted_unique(manifest.get("pendingApproval", []))
|
||||
next_after = sorted_unique(manifest.get("nextAfterApproval", []))
|
||||
overlap = sorted(set(approved) & set(pending))
|
||||
|
||||
if overlap:
|
||||
issues.append(f"units cannot be both approved and pending: {', '.join(overlap)}")
|
||||
|
||||
for stem in approved + pending + next_after:
|
||||
if stem not in rows:
|
||||
issues.append(f"manifest references unknown unit sheet: {stem}")
|
||||
|
||||
for stem in approved:
|
||||
row = rows.get(stem)
|
||||
if not row:
|
||||
continue
|
||||
if row["base"]["status"] != "ok" or row["actions"]["status"] != "ok":
|
||||
issues.append(f"approved unit has technical issue: {stem}")
|
||||
if row["review_stage"] != "approved-baseline":
|
||||
issues.append(f"approved unit has wrong review stage in audit: {stem} -> {row['review_stage']}")
|
||||
|
||||
for stem in pending:
|
||||
row = rows.get(stem)
|
||||
if not row:
|
||||
continue
|
||||
if row["base"]["status"] != "ok" or row["actions"]["status"] != "ok":
|
||||
issues.append(f"pending sample has technical issue: {stem}")
|
||||
if row["review_stage"] != "pending-approval":
|
||||
issues.append(f"pending sample has wrong review stage in audit: {stem} -> {row['review_stage']}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def release_blockers(manifest: dict, audit: dict) -> list[str]:
|
||||
blockers: list[str] = []
|
||||
pending = sorted_unique(manifest.get("pendingApproval", []))
|
||||
if pending and manifest.get("approvalPolicy", {}).get("holdCommitPushDeployUntilBatchApproved", True):
|
||||
blockers.append(f"pending approval units block commit/push/deploy: {', '.join(pending)}")
|
||||
|
||||
stage_counts = audit.get("stage_counts", {})
|
||||
if stage_counts.get("pending-approval", 0) != len(pending):
|
||||
blockers.append(
|
||||
"audit pending-approval count does not match manifest "
|
||||
f"({stage_counts.get('pending-approval', 0)} != {len(pending)})"
|
||||
)
|
||||
|
||||
return blockers
|
||||
|
||||
|
||||
def sample_summary(manifest: dict, audit: dict) -> list[str]:
|
||||
rows = row_by_stem(audit)
|
||||
pending = sorted_unique(manifest.get("pendingApproval", []))
|
||||
lines = [
|
||||
"Unit sprite sample gate: OK",
|
||||
f"approved baseline: {len(manifest.get('approvedBaseline', []))}",
|
||||
f"pending approval: {len(pending)}",
|
||||
f"queued rework: {audit.get('stage_counts', {}).get('queued-rework', 0)}",
|
||||
]
|
||||
for stem in pending:
|
||||
row = rows.get(stem)
|
||||
if row:
|
||||
base = row["base"]
|
||||
lines.append(
|
||||
f"{stem}: {row['review_stage']}, base={base['status']}, "
|
||||
f"battle={base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x "
|
||||
f"{base.get('battle_min_height_px')}..{base.get('battle_max_height_px')}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check sprite rework approval gates.")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("sample", "release"),
|
||||
default="sample",
|
||||
help="sample allows pending approval; release fails while pending approval remains.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
except Exception as exc:
|
||||
print(f"Unit sprite approval gate: FAILED\n- {exc}")
|
||||
return 1
|
||||
|
||||
issues = validate_manifest(manifest, audit)
|
||||
if issues:
|
||||
print("Unit sprite approval gate: FAILED")
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
return 1
|
||||
|
||||
if args.mode == "release":
|
||||
blockers = release_blockers(manifest, audit)
|
||||
if blockers:
|
||||
print("Unit sprite release gate: BLOCKED")
|
||||
for blocker in blockers:
|
||||
print(f"- {blocker}")
|
||||
return 2
|
||||
print("Unit sprite release gate: OK")
|
||||
return 0
|
||||
|
||||
print("\n".join(sample_summary(manifest, audit)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
243
scripts/check-unit-sprite-art-quality.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
DEFAULT_REPORT = DOCS_DIR / "unit-sprite-art-quality-gate.md"
|
||||
|
||||
|
||||
REQUIRED_SUFFIXES = [
|
||||
"contact.png",
|
||||
"animation.gif",
|
||||
"before-after.png",
|
||||
"scale-compare.png",
|
||||
"battle-composite.png",
|
||||
"debug-preview-battle-check.png",
|
||||
]
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def row_by_stem(audit: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return {row["stem"]: row for row in audit.get("rows", [])}
|
||||
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
return path.relative_to(ROOT).as_posix()
|
||||
|
||||
|
||||
def infer_prefix(candidate: dict[str, Any]) -> str:
|
||||
artifact_prefix = str(candidate.get("artifactPrefix", "")).strip()
|
||||
if artifact_prefix:
|
||||
return artifact_prefix
|
||||
|
||||
source = Path(candidate.get("source", "")).name
|
||||
version = str(candidate.get("version", "")).strip()
|
||||
if source and version and source.endswith(f"-source-{version}.png"):
|
||||
return source.removesuffix(f"-source-{version}.png") + f"-{version}"
|
||||
if source.endswith(".png"):
|
||||
return source.removesuffix(".png")
|
||||
raise ValueError("cannot infer artifact prefix from currentApprovalCandidate source")
|
||||
|
||||
|
||||
def pending_artifacts(candidate: dict[str, Any]) -> dict[str, Path]:
|
||||
prefix = infer_prefix(candidate)
|
||||
artifacts = {
|
||||
"source": ROOT / candidate["source"],
|
||||
"approval packet": ROOT / candidate["approvalPacket"],
|
||||
"current approval board": DOCS_DIR / "unit-sprite-current-approval-board.png",
|
||||
}
|
||||
for suffix in REQUIRED_SUFFIXES:
|
||||
artifacts[suffix] = DOCS_DIR / f"{prefix}-{suffix}"
|
||||
return artifacts
|
||||
|
||||
|
||||
def metric_issue(stem: str, label: str, value: float | int | None, op: str, limit: float) -> str | None:
|
||||
if value is None:
|
||||
return f"{stem}: missing metric `{label}`"
|
||||
numeric = float(value)
|
||||
passed = numeric >= limit if op == ">=" else numeric <= limit
|
||||
if passed:
|
||||
return None
|
||||
return f"{stem}: `{label}` is {numeric:g}, expected {op} {limit:g}"
|
||||
|
||||
|
||||
def validate_row(stem: str, row: dict[str, Any]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
base = row["base"]
|
||||
actions = row["actions"]
|
||||
|
||||
for key, sheet in (("base", base), ("actions", actions)):
|
||||
if sheet["status"] != "ok":
|
||||
issues.append(f"{stem}: {key} sheet status is `{sheet['status']}`")
|
||||
for field in ("partial_alpha", "border_hits", "empty_frames"):
|
||||
if sheet.get(field) not in (0, None):
|
||||
issues.append(f"{stem}: {key} `{field}` is {sheet.get(field)}, expected 0")
|
||||
|
||||
thresholds = [
|
||||
("base battle_min_width_px", base.get("battle_min_width_px"), ">=", 22.0),
|
||||
("base battle_max_width_px", base.get("battle_max_width_px"), ">=", 32.0),
|
||||
("base battle_min_height_px", base.get("battle_min_height_px"), ">=", 43.0),
|
||||
("base battle_max_height_px", base.get("battle_max_height_px"), "<=", 48.0),
|
||||
("base height_swing", base.get("height_swing"), "<=", 18.0),
|
||||
("base bottom_swing", base.get("bottom_swing"), "<=", 16.0),
|
||||
("action battle_max_width_px", actions.get("battle_max_width_px"), ">=", 38.0),
|
||||
("action battle_max_height_px", actions.get("battle_max_height_px"), ">=", 40.0),
|
||||
("action battle_max_height_px", actions.get("battle_max_height_px"), "<=", 48.0),
|
||||
("action bottom_swing", actions.get("bottom_swing"), "<=", 16.0),
|
||||
]
|
||||
for label, value, op, limit in thresholds:
|
||||
issue = metric_issue(stem, label, value, op, limit)
|
||||
if issue:
|
||||
issues.append(issue)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def validate_candidate(stem: str, candidate: dict[str, Any], row: dict[str, Any]) -> tuple[list[str], list[str]]:
|
||||
issues = validate_row(stem, row)
|
||||
evidence: list[str] = []
|
||||
artifacts = pending_artifacts(candidate)
|
||||
|
||||
for label, path in artifacts.items():
|
||||
if not path.exists():
|
||||
issues.append(f"{stem}: missing {label}: {rel(path)}")
|
||||
else:
|
||||
evidence.append(f"- {label}: `{rel(path)}`")
|
||||
|
||||
packet_path = artifacts.get("approval packet")
|
||||
if packet_path and packet_path.exists():
|
||||
packet = packet_path.read_text(encoding="utf-8")
|
||||
required_text = [
|
||||
"unit-sprite-current-approval-board.png",
|
||||
"Local browser console errors: `0`",
|
||||
f"debugSpritePreview={stem}",
|
||||
]
|
||||
for text in required_text:
|
||||
if text not in packet:
|
||||
issues.append(f"{stem}: approval packet missing `{text}`")
|
||||
|
||||
board_path = artifacts.get("current approval board")
|
||||
contact_path = artifacts.get("contact.png")
|
||||
if board_path and board_path.exists() and contact_path and contact_path.exists():
|
||||
if board_path.stat().st_mtime < contact_path.stat().st_mtime:
|
||||
issues.append(f"{stem}: current approval board is older than contact sheet")
|
||||
|
||||
return issues, evidence
|
||||
|
||||
|
||||
def write_report(
|
||||
report_path: Path,
|
||||
manifest: dict[str, Any],
|
||||
audit: dict[str, Any],
|
||||
candidate_results: list[tuple[str, list[str], list[str]]],
|
||||
) -> None:
|
||||
lines = [
|
||||
"# Unit Sprite Art Quality Gate",
|
||||
"",
|
||||
"This file is generated by `scripts/check-unit-sprite-art-quality.py`.",
|
||||
"",
|
||||
"This gate catches obvious sprite-art failures before user approval. It does not replace manual visual approval.",
|
||||
"",
|
||||
f"- Manifest: `{rel(MANIFEST_PATH)}`",
|
||||
f"- Audit: `{rel(AUDIT_PATH)}`",
|
||||
f"- Approved baseline units: {len(manifest.get('approvedBaseline', []))}",
|
||||
f"- Pending approval units: {len(manifest.get('pendingApproval', []))}",
|
||||
f"- Queued rework units: {audit.get('stage_counts', {}).get('queued-rework', 0)}",
|
||||
"",
|
||||
"## Checked Rules",
|
||||
"",
|
||||
"- Required approval artifacts exist, including contact sheet, GIF, scale comparison, battle preview, and current approval board.",
|
||||
"- Base/action sheets have no partial alpha, border hits, or empty frames.",
|
||||
"- 50px battle-scale silhouette is not too small or too tall.",
|
||||
"- Idle/walk base frames stay vertically stable.",
|
||||
"- Approval packet points at the current approval board and browser debug preview evidence.",
|
||||
"",
|
||||
]
|
||||
|
||||
failed = False
|
||||
for stem, issues, evidence in candidate_results:
|
||||
lines.extend([f"## {stem}", ""])
|
||||
if issues:
|
||||
failed = True
|
||||
lines.append("Status: FAILED")
|
||||
lines.append("")
|
||||
lines.append("Issues:")
|
||||
for issue in issues:
|
||||
lines.append(f"- {issue}")
|
||||
else:
|
||||
lines.append("Status: OK")
|
||||
lines.append("")
|
||||
if evidence:
|
||||
lines.append("Evidence:")
|
||||
lines.extend(evidence)
|
||||
lines.append("")
|
||||
|
||||
lines.extend(["## Result", "", "FAILED" if failed else "OK", ""])
|
||||
report_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check pending unit sprite art-quality evidence.")
|
||||
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
except Exception as exc:
|
||||
print(f"Unit sprite art quality gate: FAILED\n- {exc}")
|
||||
return 1
|
||||
|
||||
rows = row_by_stem(audit)
|
||||
candidates = manifest.get("currentApprovalCandidate", {})
|
||||
pending = manifest.get("pendingApproval", [])
|
||||
candidate_results: list[tuple[str, list[str], list[str]]] = []
|
||||
|
||||
if not pending:
|
||||
candidate_results.append(("none", [], ["- no pending approval units: nothing to check"]))
|
||||
for stem in pending:
|
||||
row = rows.get(stem)
|
||||
candidate = candidates.get(stem)
|
||||
if not row:
|
||||
candidate_results.append((stem, [f"{stem}: missing from audit"], []))
|
||||
continue
|
||||
if not candidate:
|
||||
candidate_results.append((stem, [f"{stem}: missing currentApprovalCandidate entry"], []))
|
||||
continue
|
||||
issues, evidence = validate_candidate(stem, candidate, row)
|
||||
candidate_results.append((stem, issues, evidence))
|
||||
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_report(args.report, manifest, audit, candidate_results)
|
||||
|
||||
failed = any(issues for _, issues, _ in candidate_results)
|
||||
if failed:
|
||||
print("Unit sprite art quality gate: FAILED")
|
||||
for stem, issues, _ in candidate_results:
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
print(f"Wrote {args.report}")
|
||||
return 1
|
||||
|
||||
print("Unit sprite art quality gate: OK")
|
||||
for stem, _, _ in candidate_results:
|
||||
print(f"- {stem}")
|
||||
print(f"Wrote {args.report}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
259
scripts/check-unit-sprite-workflow-consistency.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
NEXT_STEPS_PATH = DOCS_DIR / "unit-sprite-next-steps.md"
|
||||
PIPELINE_PATH = DOCS_DIR / "unit-sprite-handpaint-pipeline-guide.md"
|
||||
CHECKLIST_PATH = DOCS_DIR / "unit-sprite-batch-output-checklist.md"
|
||||
PLAN_PATH = DOCS_DIR / "unit-sprite-rework-plan.md"
|
||||
CURRENT_BOARD_PATH = DOCS_DIR / "unit-sprite-current-approval-board.png"
|
||||
ART_GATE_PATH = DOCS_DIR / "unit-sprite-art-quality-gate.md"
|
||||
REPORT_PATH = DOCS_DIR / "unit-sprite-workflow-consistency.md"
|
||||
|
||||
|
||||
REQUIRED_BEFORE_APPROVAL_COMMANDS = [
|
||||
"python scripts\\audit-unit-sprite-sheets.py",
|
||||
"python scripts\\render-unit-sprite-review-boards.py",
|
||||
"python scripts\\render-current-sprite-approval-board.py",
|
||||
"python scripts\\check-unit-sprite-art-quality.py",
|
||||
"python scripts\\check-unit-sprite-approval-gate.py --mode sample",
|
||||
"python scripts\\write-unit-sprite-next-steps.py",
|
||||
"python scripts\\check-unit-sprite-workflow-consistency.py",
|
||||
]
|
||||
|
||||
REQUIRED_AFTER_APPROVAL_COMMANDS = [
|
||||
"python scripts\\audit-unit-sprite-sheets.py",
|
||||
"python scripts\\render-unit-sprite-review-boards.py",
|
||||
"python scripts\\check-unit-sprite-approval-gate.py --mode release",
|
||||
"pnpm build",
|
||||
]
|
||||
|
||||
FORBIDDEN_AFTER_APPROVAL_COMMANDS = [
|
||||
"python scripts\\render-current-sprite-approval-board.py",
|
||||
"python scripts\\check-unit-sprite-art-quality.py",
|
||||
]
|
||||
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
return path.relative_to(ROOT).as_posix()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {rel(path)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {rel(path)}")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def rows_by_stem(audit: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return {row["stem"]: row for row in audit.get("rows", [])}
|
||||
|
||||
|
||||
def unique_issues(label: str, values: list[str]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
duplicates = sorted({value for value in values if values.count(value) > 1})
|
||||
for value in duplicates:
|
||||
issues.append(f"{label} contains duplicate entry: {value}")
|
||||
return issues
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict[str, Any], audit: dict[str, Any]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
rows = rows_by_stem(audit)
|
||||
approved = manifest.get("approvedBaseline", [])
|
||||
pending = manifest.get("pendingApproval", [])
|
||||
next_after = manifest.get("nextAfterApproval", [])
|
||||
candidates = manifest.get("currentApprovalCandidate", {})
|
||||
|
||||
for label, values in (
|
||||
("approvedBaseline", approved),
|
||||
("pendingApproval", pending),
|
||||
("nextAfterApproval", next_after),
|
||||
):
|
||||
if not isinstance(values, list):
|
||||
issues.append(f"{label} must be a list")
|
||||
continue
|
||||
issues.extend(unique_issues(label, values))
|
||||
|
||||
overlap_pairs = [
|
||||
("approvedBaseline", "pendingApproval", set(approved) & set(pending)),
|
||||
("approvedBaseline", "nextAfterApproval", set(approved) & set(next_after)),
|
||||
("pendingApproval", "nextAfterApproval", set(pending) & set(next_after)),
|
||||
]
|
||||
for left, right, overlap in overlap_pairs:
|
||||
if overlap:
|
||||
issues.append(f"{left} and {right} overlap: {', '.join(sorted(overlap))}")
|
||||
|
||||
for stem in approved + pending + next_after:
|
||||
if stem not in rows:
|
||||
issues.append(f"manifest references unknown unit sheet: {stem}")
|
||||
|
||||
if set(candidates) != set(pending):
|
||||
issues.append(
|
||||
"currentApprovalCandidate keys must exactly match pendingApproval "
|
||||
f"({sorted(candidates)} != {sorted(pending)})"
|
||||
)
|
||||
|
||||
for stem in pending:
|
||||
candidate = candidates.get(stem)
|
||||
if not isinstance(candidate, dict):
|
||||
issues.append(f"{stem}: currentApprovalCandidate must be an object")
|
||||
continue
|
||||
for field in ("version", "source", "artifactPrefix", "approvalPacket"):
|
||||
if not str(candidate.get(field, "")).strip():
|
||||
issues.append(f"{stem}: candidate missing `{field}`")
|
||||
for field in ("source", "approvalPacket"):
|
||||
value = str(candidate.get(field, "")).strip()
|
||||
if value and not (ROOT / value).exists():
|
||||
issues.append(f"{stem}: candidate `{field}` does not exist: {value}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def validate_docs(manifest: dict[str, Any]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
next_steps = read_text(NEXT_STEPS_PATH)
|
||||
pipeline = read_text(PIPELINE_PATH)
|
||||
checklist = read_text(CHECKLIST_PATH)
|
||||
plan = read_text(PLAN_PATH)
|
||||
combined_core_docs = "\n".join([next_steps, pipeline, checklist, plan])
|
||||
|
||||
for command in REQUIRED_BEFORE_APPROVAL_COMMANDS:
|
||||
if command not in next_steps:
|
||||
issues.append(f"next steps missing before-approval command: {command}")
|
||||
pending = manifest.get("pendingApproval", [])
|
||||
candidates = manifest.get("currentApprovalCandidate", {})
|
||||
dynamic_after_commands = REQUIRED_AFTER_APPROVAL_COMMANDS.copy()
|
||||
for stem in pending:
|
||||
candidate = candidates.get(stem, {}) if isinstance(candidates, dict) else {}
|
||||
version = candidate.get("version") if isinstance(candidate, dict) else None
|
||||
command = f"python scripts\\promote-unit-sprite-approval.py --unit {stem}"
|
||||
if version:
|
||||
command += f" --expected-version {version}"
|
||||
dynamic_after_commands.insert(0, command)
|
||||
for command in dynamic_after_commands:
|
||||
if command not in next_steps:
|
||||
issues.append(f"next steps missing after-approval command: {command}")
|
||||
after_section = next_steps.split("## Commands After Approval", 1)[-1].split("## First Task", 1)[0]
|
||||
for command in FORBIDDEN_AFTER_APPROVAL_COMMANDS:
|
||||
if command in after_section:
|
||||
issues.append(f"after-approval commands still include pending-only command: {command}")
|
||||
|
||||
stale_patterns = [
|
||||
"unit-shu-infantry` v2",
|
||||
"Shu infantry v2",
|
||||
"approved Shu infantry v2",
|
||||
"v2 restart sample",
|
||||
]
|
||||
for pattern in stale_patterns:
|
||||
if pattern in combined_core_docs:
|
||||
issues.append(f"core docs contain stale reference: {pattern}")
|
||||
|
||||
for stem in pending:
|
||||
if f"debugSpritePreview={stem}" not in combined_core_docs and stem == "unit-shu-infantry":
|
||||
issues.append(f"core docs missing debug preview reference for current pending unit: {stem}")
|
||||
|
||||
if "scripts/check-unit-sprite-art-quality.py" not in checklist:
|
||||
issues.append("batch output checklist does not include art quality gate")
|
||||
if "scripts/check-unit-sprite-art-quality.py" not in plan:
|
||||
issues.append("rework plan does not include art quality gate")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def validate_artifact_freshness(manifest: dict[str, Any]) -> list[str]:
|
||||
issues: list[str] = []
|
||||
generated_paths = [ART_GATE_PATH]
|
||||
if manifest.get("pendingApproval", []):
|
||||
generated_paths.append(CURRENT_BOARD_PATH)
|
||||
|
||||
for path in generated_paths:
|
||||
if not path.exists():
|
||||
issues.append(f"missing generated workflow artifact: {rel(path)}")
|
||||
continue
|
||||
for source in (MANIFEST_PATH, AUDIT_PATH):
|
||||
if source.exists() and path.stat().st_mtime < source.stat().st_mtime:
|
||||
issues.append(f"{rel(path)} is older than {rel(source)}")
|
||||
return issues
|
||||
|
||||
|
||||
def write_report(issues: list[str], manifest: dict[str, Any] | None = None) -> None:
|
||||
pending = manifest.get("pendingApproval", []) if manifest else []
|
||||
lines = [
|
||||
"# Unit Sprite Workflow Consistency",
|
||||
"",
|
||||
"This file is generated by `scripts/check-unit-sprite-workflow-consistency.py`.",
|
||||
"",
|
||||
"The check validates that manifest state, approval docs, and gate command lists describe the same workflow.",
|
||||
"",
|
||||
"## Result",
|
||||
"",
|
||||
"FAILED" if issues else "OK",
|
||||
"",
|
||||
]
|
||||
if issues:
|
||||
lines.extend(["## Issues", ""])
|
||||
lines.extend(f"- {issue}" for issue in issues)
|
||||
lines.append("")
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
"## Checked",
|
||||
"",
|
||||
f"- Manifest: `{rel(MANIFEST_PATH)}`",
|
||||
f"- Audit: `{rel(AUDIT_PATH)}`",
|
||||
f"- Next steps: `{rel(NEXT_STEPS_PATH)}`",
|
||||
f"- Pipeline guide: `{rel(PIPELINE_PATH)}`",
|
||||
f"- Checklist: `{rel(CHECKLIST_PATH)}`",
|
||||
f"- Rework plan: `{rel(PLAN_PATH)}`",
|
||||
f"- Art quality gate: `{rel(ART_GATE_PATH)}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if pending:
|
||||
lines.insert(-2, f"- Current board: `{rel(CURRENT_BOARD_PATH)}`")
|
||||
else:
|
||||
lines.insert(-2, "- Current board: skipped because there is no pending approval unit.")
|
||||
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
issues: list[str] = []
|
||||
manifest: dict[str, Any] | None = None
|
||||
try:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
issues.extend(validate_manifest(manifest, audit))
|
||||
issues.extend(validate_docs(manifest))
|
||||
issues.extend(validate_artifact_freshness(manifest))
|
||||
except Exception as exc:
|
||||
issues.append(str(exc))
|
||||
|
||||
write_report(issues, manifest)
|
||||
if issues:
|
||||
print("Unit sprite workflow consistency: FAILED")
|
||||
for issue in issues:
|
||||
print(f"- {issue}")
|
||||
print(f"Wrote {REPORT_PATH}")
|
||||
return 1
|
||||
|
||||
print("Unit sprite workflow consistency: OK")
|
||||
print(f"Wrote {REPORT_PATH}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
101
scripts/promote-unit-sprite-approval.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_PATH = ROOT / "docs" / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = ROOT / "docs" / "unit-sprite-rework-audit.json"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def write_manifest(manifest: dict[str, Any]) -> None:
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def audit_row(audit: dict[str, Any], unit: str) -> dict[str, Any] | None:
|
||||
return next((row for row in audit.get("rows", []) if row.get("stem") == unit), None)
|
||||
|
||||
|
||||
def require_ok_sample(unit: str, audit: dict[str, Any]) -> None:
|
||||
row = audit_row(audit, unit)
|
||||
if row is None:
|
||||
raise ValueError(f"{unit}: missing from audit")
|
||||
if row.get("review_stage") != "pending-approval":
|
||||
raise ValueError(f"{unit}: expected pending-approval stage, got {row.get('review_stage')}")
|
||||
if row.get("base", {}).get("status") != "ok" or row.get("actions", {}).get("status") != "ok":
|
||||
raise ValueError(f"{unit}: base/actions audit must both be ok before promotion")
|
||||
|
||||
|
||||
def promote(manifest: dict[str, Any], unit: str, expected_version: str | None) -> dict[str, Any]:
|
||||
pending = list(manifest.get("pendingApproval", []))
|
||||
approved = list(manifest.get("approvedBaseline", []))
|
||||
candidates = manifest.get("currentApprovalCandidate", {})
|
||||
candidate = candidates.get(unit, {})
|
||||
|
||||
if unit not in pending:
|
||||
raise ValueError(f"{unit}: not listed in pendingApproval")
|
||||
if unit in approved:
|
||||
raise ValueError(f"{unit}: already listed in approvedBaseline")
|
||||
if expected_version and candidate.get("version") != expected_version:
|
||||
raise ValueError(
|
||||
f"{unit}: expected candidate version {expected_version}, got {candidate.get('version')}"
|
||||
)
|
||||
|
||||
manifest["pendingApproval"] = [item for item in pending if item != unit]
|
||||
manifest["approvedBaseline"] = sorted([*approved, unit])
|
||||
|
||||
if isinstance(candidates, dict):
|
||||
candidates.pop(unit, None)
|
||||
manifest["currentApprovalCandidate"] = candidates
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Promote a user-approved pending unit sprite sample into approvedBaseline."
|
||||
)
|
||||
parser.add_argument("--unit", required=True, help="Unit texture key, for example unit-shu-infantry.")
|
||||
parser.add_argument("--expected-version", help="Optional candidate version guard, for example v3.")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate and print the resulting manifest status without writing the file.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
require_ok_sample(args.unit, audit)
|
||||
next_manifest = promote(manifest, args.unit, args.expected_version)
|
||||
except Exception as exc:
|
||||
print(f"Unit sprite approval promotion: FAILED\n- {exc}")
|
||||
return 1
|
||||
|
||||
approved_count = len(next_manifest.get("approvedBaseline", []))
|
||||
pending_count = len(next_manifest.get("pendingApproval", []))
|
||||
|
||||
if args.dry_run:
|
||||
print("Unit sprite approval promotion: DRY RUN OK")
|
||||
else:
|
||||
write_manifest(next_manifest)
|
||||
print("Unit sprite approval promotion: OK")
|
||||
print(f"- promoted: {args.unit}")
|
||||
print(f"- approved baseline: {approved_count}")
|
||||
print(f"- pending approval: {pending_count}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
158
scripts/render-current-sprite-approval-board.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
OUTPUT_PATH = DOCS_DIR / "unit-sprite-current-approval-board.png"
|
||||
|
||||
|
||||
def font(size: int = 14):
|
||||
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def audit_row(audit: dict[str, Any], stem: str) -> dict[str, Any] | None:
|
||||
return next((row for row in audit.get("rows", []) if row.get("stem") == stem), None)
|
||||
|
||||
|
||||
def infer_prefix(candidate: dict[str, Any]) -> str:
|
||||
artifact_prefix = str(candidate.get("artifactPrefix", "")).strip()
|
||||
if artifact_prefix:
|
||||
return artifact_prefix
|
||||
|
||||
source = Path(candidate.get("source", "")).name
|
||||
version = candidate.get("version")
|
||||
if source and version and source.endswith(f"-source-{version}.png"):
|
||||
return source.removesuffix(f"-source-{version}.png") + f"-{version}"
|
||||
if source and source.endswith(".png"):
|
||||
return source.removesuffix(".png")
|
||||
raise ValueError("cannot infer artifact prefix from currentApprovalCandidate source")
|
||||
|
||||
|
||||
def fit_image(path: Path, box: tuple[int, int], title: str) -> Image.Image:
|
||||
width, height = box
|
||||
panel = Image.new("RGBA", (width, height), (24, 30, 27, 255))
|
||||
draw = ImageDraw.Draw(panel)
|
||||
draw.rectangle((0, 0, width - 1, height - 1), outline=(105, 120, 94, 255), width=1)
|
||||
draw.text((12, 10), title, fill=(240, 226, 178, 255), font=font(14))
|
||||
|
||||
if not path.exists():
|
||||
draw.text((12, 44), f"Missing: {path.name}", fill=(230, 128, 104, 255), font=font(12))
|
||||
return panel
|
||||
|
||||
image = Image.open(path).convert("RGBA")
|
||||
image.thumbnail((width - 24, height - 56), Image.Resampling.LANCZOS)
|
||||
x = (width - image.width) // 2
|
||||
y = 44 + (height - 56 - image.height) // 2
|
||||
panel.alpha_composite(image, (x, y))
|
||||
return panel
|
||||
|
||||
|
||||
def write_text_block(draw: ImageDraw.ImageDraw, x: int, y: int, lines: list[str], fill=(222, 214, 174, 255)) -> None:
|
||||
small = font(12)
|
||||
for index, line in enumerate(lines):
|
||||
draw.text((x, y + index * 21), line, fill=fill, font=small)
|
||||
|
||||
|
||||
def render_board() -> Path:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
pending = manifest.get("pendingApproval", [])
|
||||
if not pending:
|
||||
raise ValueError("no pendingApproval unit to render")
|
||||
|
||||
stem = pending[0]
|
||||
candidates = manifest.get("currentApprovalCandidate", {})
|
||||
candidate = candidates.get(stem)
|
||||
if not candidate:
|
||||
raise ValueError(f"{stem}: missing currentApprovalCandidate entry")
|
||||
|
||||
prefix = infer_prefix(candidate)
|
||||
row = audit_row(audit, stem)
|
||||
base = row.get("base", {}) if row else {}
|
||||
|
||||
paths = {
|
||||
"Contact Sheet": DOCS_DIR / f"{prefix}-contact.png",
|
||||
"50px / 72px Scale": DOCS_DIR / f"{prefix}-scale-compare.png",
|
||||
"Debug Battle Preview": DOCS_DIR / f"{prefix}-debug-preview-battle-check.png",
|
||||
"Approved + Pending Board": DOCS_DIR / "unit-sprite-review-approved-and-pending.png",
|
||||
}
|
||||
|
||||
width = 1280
|
||||
height = 1320
|
||||
pad = 24
|
||||
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
title_font = font(24)
|
||||
body_font = font(13)
|
||||
|
||||
draw.text((pad, 18), f"Current Unit Sprite Approval Board: {stem} {candidate.get('version', '')}", fill=(245, 232, 183, 255), font=title_font)
|
||||
draw.text(
|
||||
(pad, 52),
|
||||
"Generated from the current manifest. This board is for visual approval only; it does not promote, commit, push, or deploy.",
|
||||
fill=(178, 166, 127, 255),
|
||||
font=body_font,
|
||||
)
|
||||
|
||||
info_lines = [
|
||||
f"status: pending-approval",
|
||||
f"source: {Path(candidate.get('source', '')).name}",
|
||||
f"approval packet: {Path(candidate.get('approvalPacket', '')).name}",
|
||||
"art quality gate: unit-sprite-art-quality-gate.md",
|
||||
f"base status: {base.get('status', 'unknown')}",
|
||||
f"battle bbox: {base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x {base.get('battle_min_height_px')}..{base.get('battle_max_height_px')} px",
|
||||
"release gate: blocked until explicit approval",
|
||||
]
|
||||
write_text_block(draw, pad, 84, info_lines)
|
||||
|
||||
top_y = 230
|
||||
left_w = 888
|
||||
right_w = width - pad * 3 - left_w
|
||||
out.alpha_composite(fit_image(paths["Contact Sheet"], (left_w, 450), "Contact Sheet"), (pad, top_y))
|
||||
out.alpha_composite(fit_image(paths["50px / 72px Scale"], (right_w, 216), "50px / 72px Scale"), (pad * 2 + left_w, top_y))
|
||||
out.alpha_composite(fit_image(paths["Approved + Pending Board"], (right_w, 216), "Approved + Pending"), (pad * 2 + left_w, top_y + 234))
|
||||
|
||||
bottom_y = top_y + 474
|
||||
out.alpha_composite(fit_image(paths["Debug Battle Preview"], (width - pad * 2, 430), "Real Battle Debug Preview"), (pad, bottom_y))
|
||||
|
||||
command_y = bottom_y + 446
|
||||
draw.text((pad, command_y), "After explicit approval:", fill=(240, 226, 178, 255), font=font(15))
|
||||
commands = [
|
||||
f"python scripts\\promote-unit-sprite-approval.py --unit {stem} --expected-version {candidate.get('version')}",
|
||||
"python scripts\\audit-unit-sprite-sheets.py",
|
||||
"python scripts\\render-unit-sprite-review-boards.py",
|
||||
"python scripts\\check-unit-sprite-art-quality.py",
|
||||
"python scripts\\check-unit-sprite-approval-gate.py --mode release",
|
||||
]
|
||||
write_text_block(draw, pad, command_y + 28, commands, fill=(203, 214, 198, 255))
|
||||
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.convert("RGB").save(OUTPUT_PATH, optimize=True)
|
||||
return OUTPUT_PATH
|
||||
|
||||
|
||||
def main() -> None:
|
||||
path = render_board()
|
||||
print(f"Wrote {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
202
scripts/render-unit-sprite-review-boards.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UNIT_DIR = ROOT / "src" / "assets" / "images" / "units"
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
AUDIT_JSON = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
FRAME = 313
|
||||
|
||||
|
||||
def font(size: int = 12):
|
||||
for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def checker_background(size: tuple[int, int], tile: int = 8) -> Image.Image:
|
||||
image = Image.new("RGBA", size, (57, 76, 48, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], tile):
|
||||
for x in range(0, size[0], tile):
|
||||
color = (68, 90, 55, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (48, 64, 43, 255)
|
||||
draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color)
|
||||
return image
|
||||
|
||||
|
||||
def frame_for(stem: str, row = 0, col = 0) -> Image.Image:
|
||||
image = Image.open(UNIT_DIR / f"{stem}.png").convert("RGBA")
|
||||
return image.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME))
|
||||
|
||||
|
||||
def render_sprite(frame: Image.Image, size: int) -> Image.Image:
|
||||
tile = checker_background((size, size))
|
||||
scaled = frame.resize((size, size), Image.Resampling.LANCZOS)
|
||||
tile.alpha_composite(scaled, (0, 0))
|
||||
return tile
|
||||
|
||||
|
||||
def label_for(stem: str) -> str:
|
||||
return stem.replace("unit-", "")
|
||||
|
||||
|
||||
def stage_label(stage: str) -> str:
|
||||
return {
|
||||
"approved-baseline": "approved baseline",
|
||||
"pending-approval": "pending sample",
|
||||
"queued-rework": "old queued asset",
|
||||
}.get(stage, stage)
|
||||
|
||||
|
||||
def stage_color(stage: str) -> tuple[int, int, int, int]:
|
||||
if stage == "approved-baseline":
|
||||
return (103, 151, 93, 255)
|
||||
if stage == "pending-approval":
|
||||
return (218, 176, 84, 255)
|
||||
return (90, 101, 91, 255)
|
||||
|
||||
|
||||
def render_batch_board(batch: str, rows: list[dict]) -> Path:
|
||||
rows = sorted(rows, key=lambda item: item["stem"])
|
||||
columns = 5
|
||||
card_w = 172
|
||||
card_h = 142
|
||||
pad = 18
|
||||
header_h = 54
|
||||
width = pad + columns * card_w + pad
|
||||
height = header_h + ((len(rows) + columns - 1) // columns) * card_h + pad
|
||||
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
title_font = font(18)
|
||||
body_font = font(11)
|
||||
small_font = font(9)
|
||||
draw.text((pad, 16), f"{batch} review board", fill=(240, 226, 178, 255), font=title_font)
|
||||
|
||||
for index, row in enumerate(rows):
|
||||
x = pad + (index % columns) * card_w
|
||||
y = header_h + (index // columns) * card_h
|
||||
stem = row["stem"]
|
||||
base_status = row["base"]["status"]
|
||||
action_status = row["actions"]["status"]
|
||||
status_ok = base_status == "ok" and action_status == "ok"
|
||||
stage = row.get("review_stage", "queued-rework")
|
||||
border = stage_color(stage) if status_ok else (156, 105, 67, 255)
|
||||
draw.rectangle((x, y, x + card_w - 12, y + card_h - 12), fill=(24, 30, 27, 255), outline=border, width=1)
|
||||
|
||||
frame = frame_for(stem)
|
||||
out.alpha_composite(render_sprite(frame, 72), (x + 10, y + 10))
|
||||
out.alpha_composite(render_sprite(frame, 50), (x + 94, y + 21))
|
||||
draw.text((x + 10, y + 88), label_for(stem), fill=(238, 224, 178, 255), font=body_font)
|
||||
status_text = stage_label(stage) if status_ok else "technical issue"
|
||||
draw.text((x + 10, y + 108), status_text, fill=border, font=small_font)
|
||||
if stage == "queued-rework":
|
||||
draw.text((x + 94, y + 75), "not new", fill=(151, 137, 105, 255), font=small_font)
|
||||
|
||||
path = DOCS_DIR / f"unit-sprite-review-{batch}.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def render_deliverable_board(rows: list[dict]) -> Path:
|
||||
deliverables = [
|
||||
row
|
||||
for row in rows
|
||||
if row.get("review_stage") in {"approved-baseline", "pending-approval"}
|
||||
]
|
||||
order = {"approved-baseline": 0, "pending-approval": 1}
|
||||
deliverables = sorted(deliverables, key=lambda item: (order[item["review_stage"]], item["stem"]))
|
||||
|
||||
columns = 5
|
||||
card_w = 172
|
||||
card_h = 148
|
||||
pad = 18
|
||||
header_h = 70
|
||||
width = pad + columns * card_w + pad
|
||||
height = header_h + ((len(deliverables) + columns - 1) // columns) * card_h + pad
|
||||
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
draw.text((pad, 16), "Approved + Pending Sprite Deliverables", fill=(240, 226, 178, 255), font=font(18))
|
||||
draw.text(
|
||||
(pad, 42),
|
||||
"Queued old assets are intentionally excluded from this board.",
|
||||
fill=(174, 161, 124, 255),
|
||||
font=font(11),
|
||||
)
|
||||
|
||||
for index, row in enumerate(deliverables):
|
||||
x = pad + (index % columns) * card_w
|
||||
y = header_h + (index // columns) * card_h
|
||||
stem = row["stem"]
|
||||
stage = row.get("review_stage", "queued-rework")
|
||||
border = stage_color(stage)
|
||||
draw.rectangle((x, y, x + card_w - 12, y + card_h - 12), fill=(24, 30, 27, 255), outline=border, width=1)
|
||||
frame = frame_for(stem)
|
||||
out.alpha_composite(render_sprite(frame, 72), (x + 10, y + 10))
|
||||
out.alpha_composite(render_sprite(frame, 50), (x + 94, y + 21))
|
||||
draw.text((x + 10, y + 88), label_for(stem), fill=(238, 224, 178, 255), font=font(11))
|
||||
draw.text((x + 10, y + 110), stage_label(stage), fill=border, font=font(9))
|
||||
|
||||
path = DOCS_DIR / "unit-sprite-review-approved-and-pending.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def render_all_board(groups: dict[str, list[dict]], paths: list[Path]) -> Path:
|
||||
thumb_w = 212
|
||||
thumb_h = 118
|
||||
pad = 18
|
||||
cols = 2
|
||||
width = pad + cols * thumb_w + pad
|
||||
height = 58 + ((len(paths) + cols - 1) // cols) * thumb_h + pad
|
||||
out = Image.new("RGBA", (width, height), (18, 22, 20, 255))
|
||||
draw = ImageDraw.Draw(out)
|
||||
draw.text((pad, 16), "Unit Sprite Batch Review Index", fill=(240, 226, 178, 255), font=font(18))
|
||||
for index, path in enumerate(paths):
|
||||
batch = path.stem.replace("unit-sprite-review-", "")
|
||||
x = pad + (index % cols) * thumb_w
|
||||
y = 58 + (index // cols) * thumb_h
|
||||
preview = Image.open(path).convert("RGBA")
|
||||
preview.thumbnail((190, 84), Image.Resampling.LANCZOS)
|
||||
draw.rectangle((x, y, x + thumb_w - 12, y + thumb_h - 12), fill=(24, 30, 27, 255), outline=(85, 95, 75, 255))
|
||||
out.alpha_composite(preview, (x + 10, y + 10))
|
||||
approved_count = sum(1 for row in groups[batch] if row.get("review_stage") == "approved-baseline")
|
||||
pending_count = sum(1 for row in groups[batch] if row.get("review_stage") == "pending-approval")
|
||||
draw.text(
|
||||
(x + 10, y + 92),
|
||||
f"{batch}: {approved_count} approved, {pending_count} pending",
|
||||
fill=(238, 224, 178, 255),
|
||||
font=font(10),
|
||||
)
|
||||
path = DOCS_DIR / "unit-sprite-review-index.png"
|
||||
out.convert("RGB").save(path, optimize=True)
|
||||
return path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
audit = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
|
||||
groups: dict[str, list[dict]] = defaultdict(list)
|
||||
all_rows: list[dict] = []
|
||||
for row in audit["rows"]:
|
||||
groups[row["batch"]].append(row)
|
||||
all_rows.append(row)
|
||||
|
||||
paths = [render_batch_board(batch, groups[batch]) for batch in sorted(groups)]
|
||||
deliverables = render_deliverable_board(all_rows)
|
||||
index = render_all_board(groups, paths)
|
||||
print(f"Wrote {index}")
|
||||
print(f"Wrote {deliverables}")
|
||||
for path in paths:
|
||||
print(f"Wrote {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
219
scripts/write-unit-sprite-batch-queue.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
MARKDOWN_PATH = DOCS_DIR / "unit-sprite-batch-queue.md"
|
||||
JSON_PATH = DOCS_DIR / "unit-sprite-batch-queue.json"
|
||||
|
||||
|
||||
BATCH_ORDER = [
|
||||
"batch1-approved-core",
|
||||
"batch1-approved-rebel",
|
||||
"batch2-shu",
|
||||
"batch3-wei",
|
||||
"batch4-wu",
|
||||
"batch5-rebel-other",
|
||||
"batch6-nanman-special",
|
||||
"batch7-unique-officer",
|
||||
]
|
||||
|
||||
BATCH_LABELS = {
|
||||
"batch1-approved-core": "Batch 1 approved core heroes",
|
||||
"batch1-approved-rebel": "Batch 1 approved rebel references",
|
||||
"batch2-shu": "Batch 2 Shu generic units",
|
||||
"batch3-wei": "Batch 3 Wei generic units",
|
||||
"batch4-wu": "Batch 4 Wu generic units",
|
||||
"batch5-rebel-other": "Batch 5 remaining rebel/bandit units",
|
||||
"batch6-nanman-special": "Batch 6 Nanman/special units",
|
||||
"batch7-unique-officer": "Batch 7 unique officers",
|
||||
}
|
||||
|
||||
SPEC_BY_STEM = {
|
||||
"unit-shu-spearman": "unit-sprite-batch2-shu-spearman-spec.md",
|
||||
"unit-shu-archer": "unit-sprite-batch2-shu-archer-spec.md",
|
||||
"unit-shu-cavalry": "unit-sprite-batch2-shu-cavalry-spec.md",
|
||||
"unit-shu-strategist": "unit-sprite-batch2-shu-strategist-spec.md",
|
||||
"unit-shu-officer": "unit-sprite-batch2-shu-officer-spec.md",
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def row_sort_key(row: dict[str, Any], manifest: dict[str, Any]) -> tuple[int, int, str]:
|
||||
stem = row["stem"]
|
||||
pending = manifest.get("pendingApproval", [])
|
||||
next_after = manifest.get("nextAfterApproval", [])
|
||||
if stem in pending:
|
||||
return (0, pending.index(stem), stem)
|
||||
if stem in next_after:
|
||||
return (1, next_after.index(stem), stem)
|
||||
if row.get("review_stage") == "approved-baseline":
|
||||
return (3, 0, stem)
|
||||
return (2, 0, stem)
|
||||
|
||||
|
||||
def summarize_status(row: dict[str, Any]) -> str:
|
||||
base = row["base"]["status"]
|
||||
actions = row["actions"]["status"]
|
||||
if base == "ok" and actions == "ok":
|
||||
return "technical ok"
|
||||
return f"base {base}; actions {actions}"
|
||||
|
||||
|
||||
def queue_data(manifest: dict[str, Any], audit: dict[str, Any]) -> dict[str, Any]:
|
||||
rows = audit.get("rows", [])
|
||||
batches: list[dict[str, Any]] = []
|
||||
pending = set(manifest.get("pendingApproval", []))
|
||||
approved = set(manifest.get("approvedBaseline", []))
|
||||
next_after = set(manifest.get("nextAfterApproval", []))
|
||||
|
||||
for batch in BATCH_ORDER:
|
||||
batch_rows = [row for row in rows if row.get("batch") == batch]
|
||||
batch_rows = sorted(batch_rows, key=lambda row: row_sort_key(row, manifest))
|
||||
stage_counts = Counter(row.get("review_stage", "queued-rework") for row in batch_rows)
|
||||
units: list[dict[str, Any]] = []
|
||||
for row in batch_rows:
|
||||
stem = row["stem"]
|
||||
queue_role = "approved reference"
|
||||
if stem in pending:
|
||||
queue_role = "current approval sample"
|
||||
elif stem in next_after:
|
||||
queue_role = "next primary queue"
|
||||
elif stem not in approved:
|
||||
queue_role = "queued rework"
|
||||
units.append(
|
||||
{
|
||||
"stem": stem,
|
||||
"reviewStage": row.get("review_stage"),
|
||||
"queueRole": queue_role,
|
||||
"baseStatus": row["base"]["status"],
|
||||
"actionStatus": row["actions"]["status"],
|
||||
"battleBasePx": [
|
||||
row["base"].get("battle_min_width_px"),
|
||||
row["base"].get("battle_max_width_px"),
|
||||
row["base"].get("battle_min_height_px"),
|
||||
row["base"].get("battle_max_height_px"),
|
||||
],
|
||||
"spec": SPEC_BY_STEM.get(stem),
|
||||
}
|
||||
)
|
||||
batches.append(
|
||||
{
|
||||
"key": batch,
|
||||
"label": BATCH_LABELS.get(batch, batch),
|
||||
"total": len(batch_rows),
|
||||
"stageCounts": dict(sorted(stage_counts.items())),
|
||||
"units": units,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": 1,
|
||||
"manifest": MANIFEST_PATH.relative_to(ROOT).as_posix(),
|
||||
"audit": AUDIT_PATH.relative_to(ROOT).as_posix(),
|
||||
"approvedBaseline": manifest.get("approvedBaseline", []),
|
||||
"pendingApproval": manifest.get("pendingApproval", []),
|
||||
"nextAfterApproval": manifest.get("nextAfterApproval", []),
|
||||
"stageCounts": audit.get("stage_counts", {}),
|
||||
"batches": batches,
|
||||
}
|
||||
|
||||
|
||||
def write_markdown(data: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
"# Unit Sprite Batch Queue",
|
||||
"",
|
||||
"This file is generated by `scripts/write-unit-sprite-batch-queue.py`.",
|
||||
"",
|
||||
"Use this as the long-running queue for the full hand-painted sprite rework. It is inventory and sequencing, not approval by itself.",
|
||||
"",
|
||||
"## Current State",
|
||||
"",
|
||||
f"- Approved baseline units: {len(data['approvedBaseline'])}",
|
||||
f"- Pending approval units: {len(data['pendingApproval'])}",
|
||||
f"- Queued rework units: {data.get('stageCounts', {}).get('queued-rework', 0)}",
|
||||
f"- Manifest: `{data['manifest']}`",
|
||||
f"- Audit: `{data['audit']}`",
|
||||
"",
|
||||
]
|
||||
|
||||
pending = data["pendingApproval"]
|
||||
if pending:
|
||||
lines.extend(
|
||||
[
|
||||
"## Approval Blocker",
|
||||
"",
|
||||
"Do not start the next unit, commit, push, or deploy while these units are pending:",
|
||||
"",
|
||||
]
|
||||
)
|
||||
for stem in pending:
|
||||
lines.append(f"- `{stem}`")
|
||||
lines.append("")
|
||||
|
||||
lines.extend(["## Next After Approval", ""])
|
||||
next_after = data["nextAfterApproval"]
|
||||
if next_after:
|
||||
for index, stem in enumerate(next_after, start=1):
|
||||
spec = SPEC_BY_STEM.get(stem)
|
||||
spec_note = f" - spec `{spec}`" if spec else ""
|
||||
lines.append(f"{index}. `{stem}`{spec_note}")
|
||||
else:
|
||||
lines.append("No next unit is currently defined.")
|
||||
lines.append("")
|
||||
|
||||
lines.extend(["## Batch Queue", ""])
|
||||
for batch in data["batches"]:
|
||||
lines.extend(
|
||||
[
|
||||
f"### {batch['label']}",
|
||||
"",
|
||||
f"- Batch key: `{batch['key']}`",
|
||||
f"- Total units: {batch['total']}",
|
||||
f"- Stage counts: {', '.join(f'{key} {value}' for key, value in batch['stageCounts'].items()) or 'none'}",
|
||||
"",
|
||||
"| unit | role | stage | status | battle base px | spec |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
for unit in batch["units"]:
|
||||
battle = unit["battleBasePx"]
|
||||
battle_text = f"{battle[0]}..{battle[1]} x {battle[2]}..{battle[3]}"
|
||||
spec = f"`{unit['spec']}`" if unit["spec"] else ""
|
||||
status = (
|
||||
"technical ok"
|
||||
if unit["baseStatus"] == "ok" and unit["actionStatus"] == "ok"
|
||||
else f"base {unit['baseStatus']}; actions {unit['actionStatus']}"
|
||||
)
|
||||
lines.append(
|
||||
f"| `{unit['stem']}` | {unit['queueRole']} | {unit['reviewStage']} | {status} | {battle_text} | {spec} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
MARKDOWN_PATH.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
data = queue_data(manifest, audit)
|
||||
JSON_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
write_markdown(data)
|
||||
print(f"Wrote {JSON_PATH}")
|
||||
print(f"Wrote {MARKDOWN_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
scripts/write-unit-sprite-next-steps.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json"
|
||||
AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json"
|
||||
DEFAULT_OUTPUT = DOCS_DIR / "unit-sprite-next-steps.md"
|
||||
SPEC_BY_STEM = {
|
||||
"unit-shu-spearman": "unit-sprite-batch2-shu-spearman-spec.md",
|
||||
"unit-shu-archer": "unit-sprite-batch2-shu-archer-spec.md",
|
||||
"unit-shu-cavalry": "unit-sprite-batch2-shu-cavalry-spec.md",
|
||||
"unit-shu-strategist": "unit-sprite-batch2-shu-strategist-spec.md",
|
||||
"unit-shu-officer": "unit-sprite-batch2-shu-officer-spec.md",
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def row_by_stem(audit: dict) -> dict[str, dict]:
|
||||
return {row["stem"]: row for row in audit.get("rows", [])}
|
||||
|
||||
|
||||
def command_block(commands: list[str]) -> list[str]:
|
||||
return ["```powershell", *commands, "```"]
|
||||
|
||||
|
||||
def write_report(output: Path) -> None:
|
||||
manifest = load_json(MANIFEST_PATH)
|
||||
audit = load_json(AUDIT_PATH)
|
||||
rows = row_by_stem(audit)
|
||||
approved = manifest.get("approvedBaseline", [])
|
||||
pending = manifest.get("pendingApproval", [])
|
||||
candidates = manifest.get("currentApprovalCandidate", {})
|
||||
next_after = manifest.get("nextAfterApproval", [])
|
||||
stage_counts = audit.get("stage_counts", {})
|
||||
|
||||
lines = [
|
||||
"# Unit Sprite Next Steps",
|
||||
"",
|
||||
"This file is generated by `scripts/write-unit-sprite-next-steps.py`.",
|
||||
"",
|
||||
"## Current Gate",
|
||||
"",
|
||||
f"- Approved baseline units: {len(approved)}",
|
||||
f"- Pending approval units: {len(pending)}",
|
||||
f"- Queued rework units: {stage_counts.get('queued-rework', 0)}",
|
||||
f"- Manifest: `{MANIFEST_PATH.relative_to(ROOT).as_posix()}`",
|
||||
f"- Audit: `{AUDIT_PATH.relative_to(ROOT).as_posix()}`",
|
||||
"- Batch inventory boards include old queued assets; use them for inventory/regression checks, not approval.",
|
||||
"",
|
||||
]
|
||||
if pending:
|
||||
lines.insert(-2, "- Current approval board: `docs/unit-sprite-current-approval-board.png`")
|
||||
else:
|
||||
lines.insert(-2, "- Current approval board: not generated until the next pending sample.")
|
||||
|
||||
if pending:
|
||||
lines.extend(
|
||||
[
|
||||
"## Approval Blocker",
|
||||
"",
|
||||
"Do not expand to the next unit, commit, push, or deploy while these units remain pending:",
|
||||
"",
|
||||
]
|
||||
)
|
||||
for stem in pending:
|
||||
row = rows.get(stem)
|
||||
if row:
|
||||
base = row["base"]
|
||||
lines.append(
|
||||
f"- `{stem}`: {row['review_stage']}, base `{base['status']}`, "
|
||||
f"battle bbox `{base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x "
|
||||
f"{base.get('battle_min_height_px')}..{base.get('battle_max_height_px')}`"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- `{stem}`: missing from audit")
|
||||
lines.append("")
|
||||
|
||||
promote_commands = []
|
||||
if pending:
|
||||
unit = pending[0]
|
||||
version = candidates.get(unit, {}).get("version") if isinstance(candidates, dict) else None
|
||||
command = f"python scripts\\promote-unit-sprite-approval.py --unit {unit}"
|
||||
if version:
|
||||
command += f" --expected-version {version}"
|
||||
promote_commands.append(command)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"## Next Unit Queue",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if next_after:
|
||||
for index, stem in enumerate(next_after, start=1):
|
||||
row = rows.get(stem)
|
||||
status = "missing from audit"
|
||||
if row:
|
||||
status = f"{row['review_stage']}, base `{row['base']['status']}`, actions `{row['actions']['status']}`"
|
||||
spec = SPEC_BY_STEM.get(stem)
|
||||
spec_note = f", spec `{spec}`" if spec else ""
|
||||
lines.append(f"{index}. `{stem}` - {status}{spec_note}")
|
||||
else:
|
||||
lines.append("No queued next unit is defined in the manifest.")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Commands Before Approval",
|
||||
"",
|
||||
*command_block(
|
||||
[
|
||||
"python scripts\\audit-unit-sprite-sheets.py",
|
||||
"python scripts\\render-unit-sprite-review-boards.py",
|
||||
"python scripts\\render-current-sprite-approval-board.py",
|
||||
"python scripts\\check-unit-sprite-art-quality.py",
|
||||
"python scripts\\check-unit-sprite-approval-gate.py --mode sample",
|
||||
"python scripts\\write-unit-sprite-next-steps.py",
|
||||
"python scripts\\check-unit-sprite-workflow-consistency.py",
|
||||
]
|
||||
),
|
||||
"",
|
||||
"## Commands After Approval",
|
||||
"",
|
||||
"After user approval, promote the approved unit in `unit-sprite-rework-manifest.json`, regenerate outputs, and only then use the release gate.",
|
||||
"",
|
||||
*command_block(
|
||||
[
|
||||
*promote_commands,
|
||||
"python scripts\\audit-unit-sprite-sheets.py",
|
||||
"python scripts\\render-unit-sprite-review-boards.py",
|
||||
"python scripts\\check-unit-sprite-approval-gate.py --mode release",
|
||||
"pnpm build",
|
||||
]
|
||||
),
|
||||
"",
|
||||
"## First Task After Current Approval",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if next_after:
|
||||
first = next_after[0]
|
||||
spec = SPEC_BY_STEM.get(first, "")
|
||||
lines.append(f"- Start with `{first}`.")
|
||||
if spec:
|
||||
lines.append(f"- Use spec: `{spec}`.")
|
||||
lines.append("- Generate source art, assemble sheets, produce contact/GIF/before-after/scale/battle screenshots, then rerun the gates.")
|
||||
else:
|
||||
lines.append("- No first task is defined.")
|
||||
|
||||
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Write the next-step report for sprite rework.")
|
||||
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT)
|
||||
args = parser.parse_args()
|
||||
write_report(args.out)
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 517 KiB After Width: | Height: | Size: 898 KiB |
@@ -58,6 +58,10 @@ export function unitTextureVariantKeys(baseKey: string) {
|
||||
return keys.length > 0 ? keys : [baseKey];
|
||||
}
|
||||
|
||||
export function hasUnitSheetAsset(key: string) {
|
||||
return unitSheetAssetsByKey.has(key);
|
||||
}
|
||||
|
||||
export function loadUnitSheets(scene: Phaser.Scene, keys: Iterable<string>, onReady: () => void) {
|
||||
const uniqueKeys = Array.from(new Set(keys));
|
||||
const missingSheets = uniqueKeys
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultBattleScenario, getBattleScenario, type BattleObjectiveDefinitio
|
||||
import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules';
|
||||
import {
|
||||
ensureUnitAnimations,
|
||||
hasUnitSheetAsset,
|
||||
loadUnitSheets,
|
||||
unitBaseFramesPerDirection,
|
||||
unitTextureVariantKeys as unitAssetTextureVariantKeys,
|
||||
@@ -2995,6 +2996,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.drawMap();
|
||||
this.drawUnits();
|
||||
this.drawSidePanel();
|
||||
this.drawDebugSpritePreview();
|
||||
this.updateTurnText();
|
||||
this.time.delayedCall(180, () => this.showOpeningBattleEvent());
|
||||
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
|
||||
@@ -3038,7 +3040,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private ensureScenarioUnitTextures(onReady: () => void) {
|
||||
const textureKeys = Array.from(new Set(battleUnits.map((unit) => this.unitTextureKey(unit))));
|
||||
const textureKeys = Array.from(new Set([...battleUnits.map((unit) => this.unitTextureKey(unit)), ...this.debugSpritePreviewKeys()]));
|
||||
const texturesReady = textureKeys.every((key) => this.textures.exists(key) && this.textures.exists(`${key}-actions`));
|
||||
|
||||
if (texturesReady) {
|
||||
@@ -3070,6 +3072,19 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
private debugSpritePreviewKeys() {
|
||||
const key = this.debugSpritePreviewKey();
|
||||
return key ? [key] : [];
|
||||
}
|
||||
|
||||
private debugSpritePreviewKey() {
|
||||
if (!this.debugToolsEnabled() || typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
const key = new URLSearchParams(window.location.search).get('debugSpritePreview')?.trim();
|
||||
return key && hasUnitSheetAsset(key) ? key : undefined;
|
||||
}
|
||||
|
||||
private resetBattleData(campaign?: CampaignState) {
|
||||
const selected = new Set(campaign?.selectedSortieUnitIds ?? []);
|
||||
const forcedAllyIds = new Set(
|
||||
@@ -3281,6 +3296,64 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.drawMiniMap();
|
||||
}
|
||||
|
||||
private drawDebugSpritePreview() {
|
||||
const textureKey = this.debugSpritePreviewKey();
|
||||
if (!textureKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { panelX, panelY, panelWidth, panelHeight } = this.layout;
|
||||
const left = panelX + 24;
|
||||
const miniMapTop = this.miniMapLayout?.y ?? panelY + panelHeight - 156;
|
||||
const top = Math.max(panelY + 212, miniMapTop - 148);
|
||||
const width = panelWidth - 48;
|
||||
const height = 128;
|
||||
const depth = 42;
|
||||
|
||||
const panel = this.add.rectangle(left, top, width, height, 0x0b1118, 0.96);
|
||||
panel.setOrigin(0);
|
||||
panel.setDepth(depth);
|
||||
panel.setStrokeStyle(1, palette.gold, 0.78);
|
||||
|
||||
this.add.text(left + 12, top + 10, `Debug sprite preview: ${textureKey}`, {
|
||||
fontFamily: 'Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
color: '#f4dfad'
|
||||
}).setDepth(depth + 1);
|
||||
|
||||
const idle72 = this.add.sprite(left + 48, top + 78, textureKey, this.unitFrameIndex('south'));
|
||||
idle72.setDisplaySize(72, 72);
|
||||
idle72.setDepth(depth + 1);
|
||||
this.applyBaseSpriteBlend(idle72);
|
||||
|
||||
const idle50 = this.add.sprite(left + 120, top + 88, textureKey, this.unitFrameIndex('south'));
|
||||
idle50.setDisplaySize(50, 50);
|
||||
idle50.setDepth(depth + 1);
|
||||
this.applyBaseSpriteBlend(idle50);
|
||||
|
||||
const action = this.add.sprite(
|
||||
left + 196,
|
||||
top + 76,
|
||||
`${textureKey}-actions`,
|
||||
this.unitActionFrameIndex('east', 'attack', this.unitActionImpactFrame('attack'))
|
||||
);
|
||||
action.setDisplaySize(78, 78);
|
||||
action.setDepth(depth + 1);
|
||||
this.applyActionSpriteBlend(action);
|
||||
|
||||
[
|
||||
{ label: '72px', x: left + 24 },
|
||||
{ label: '50px', x: left + 104 },
|
||||
{ label: 'action', x: left + 174 }
|
||||
].forEach(({ label, x }) => {
|
||||
this.add.text(x, top + 112, label, {
|
||||
fontFamily: 'Consolas, monospace',
|
||||
fontSize: '10px',
|
||||
color: '#d4dce6'
|
||||
}).setDepth(depth + 1);
|
||||
});
|
||||
}
|
||||
|
||||
update(_time: number, delta: number) {
|
||||
this.updateEdgeScroll(delta);
|
||||
}
|
||||
|
||||