유닛 크기 = 머리(눈 간격) 기준 통일 · 기마 = 탄 사람이 보병 크기 · 위군 그림 5종 폐기(#1104 · PD 2026-11-09)

- PD 「영웅 리스트 캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」: 원인 = 그림마다 몸 키(bodyHeightPx)를 같은
  높이로 맞춰 와서, 말까지 한 그림에 든 기마는 탄 사람이 절반 크기(눈 간격/키 0.09~0.12 vs 보병 0.14~0.20)
- 크기 표 Assets/Resources/Units/art_size.csv(SOT) = 눈 간격 자동 측정(Tools/unit_art_size.py · 얼굴 피부 안 어두운 구멍 2개)
  ÷ 말 안 탄 14종 중앙값 0.1687 → size_px(머리 크기가 표준인 유닛의 키) · mounted 열만 수동(장비 · 조운 · 여포 = 1)
- 게임: FrameAnimator 배율 = size_px 기준(기마 x1.38~1.82) · TopHeight = 실제 머리 끝 → Unit.ArtHeight(HP 바 · 머리 위 표시)
- 영웅 목록 카드: 같은 px 배율 → UnitPx 450 / size_px(머리 크기 같음 · 머리 끝 위치 그대로)
- PD 「화풍이 전혀 다른 유닛 폐기」 = Resources/Units/wei_* 5종 삭제(사마의 영웅 · 위군 = 도형 대체 · heroes.csv art id 유지)
- 테스트 118/118(+1 UnitArt_SizeTable_SameHeadSize_MountedRiderLikeFoot) · console error 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-11-09 19:23:50 +09:00
parent d16d354873
commit 747b3968a8
37 changed files with 277 additions and 1065 deletions

View File

@ -0,0 +1,21 @@
art,mounted,eye_px,body_px,size_px,note
cc_archer,0,125.1,744,741.6,
cc_boss_caocao,0,135.0,808,800.3,
cc_cavalry,1,91.9,849,544.8,
cc_infantry,0,125.3,765,742.8,
cc_spear,0,124.1,785,735.7,
dz_archer,0,123.3,745,730.9,
dz_boss_lvbu,1,75.8,819,449.3,
dz_cavalry,1,96.2,873,570.3,
dz_infantry,0,139.1,844,824.6,
dz_spear,0,117.2,818,694.8,
guanyu,0,122.7,725,727.4,
hgj_archer,0,154.7,833,917.0,
hgj_boss_zhangjiao,0,147.8,854,876.1,
hgj_cavalry,1,107.4,879,636.7,
hgj_infantry,0,161.5,850,957.4,
hgj_spear,0,168.5,862,998.9,
huangzhong,0,148.8,797,882.1,
liubei,0,142.2,752,843.0,
zhangfei,1,108.2,917,641.4,
zhaoyun,1,100.4,926,595.2,
1 art mounted eye_px body_px size_px note
2 cc_archer 0 125.1 744 741.6
3 cc_boss_caocao 0 135.0 808 800.3
4 cc_cavalry 1 91.9 849 544.8
5 cc_infantry 0 125.3 765 742.8
6 cc_spear 0 124.1 785 735.7
7 dz_archer 0 123.3 745 730.9
8 dz_boss_lvbu 1 75.8 819 449.3
9 dz_cavalry 1 96.2 873 570.3
10 dz_infantry 0 139.1 844 824.6
11 dz_spear 0 117.2 818 694.8
12 guanyu 0 122.7 725 727.4
13 hgj_archer 0 154.7 833 917.0
14 hgj_boss_zhangjiao 0 147.8 854 876.1
15 hgj_cavalry 1 107.4 879 636.7
16 hgj_infantry 0 161.5 850 957.4
17 hgj_spear 0 168.5 862 998.9
18 huangzhong 0 148.8 797 882.1
19 liubei 0 142.2 752 843.0
20 zhangfei 1 108.2 917 641.4
21 zhaoyun 1 100.4 926 595.2

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 6c52ae5244a4c1d47999b26723feb8d6 guid: 7f92b435a2912a24cb826a5b40683511
TextScriptImporter: TextScriptImporter:
externalObjects: {} externalObjects: {}
userData: userData:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: c8fe4e3059806cd4198d7544e2061771
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 44bb65b9a5cf86848ab36c47523fe2a2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 524 KiB

View File

@ -1,168 +0,0 @@
fileFormatVersion: 2
guid: 154714b72b6b71a439a066069edd217a
TextureImporter:
internalIDToNameTable:
- first:
213: -166343660977832731
second: f000_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 0
alignment: 9
spritePivot: {x: 0.5, y: 0.02101}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: f000_0
rect:
serializedVersion: 2
x: 19
y: 19
width: 578
height: 914
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 5ec37c8525701bdf0800000000000000
internalID: -166343660977832731
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,21 +0,0 @@
{
"fps": 1,
"ppu": 100,
"frame": [
616,
952
],
"pivot": [
0.5,
0.02101
],
"charHeightPx": 912,
"bodyHeightPx": 842,
"clips": [
{
"name": "front_idle",
"frames": 1,
"loop": true
}
]
}

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 357942899e8b8504792baf9a75f4795f
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 321a9b9e1c1291640bb97fc92b2924c9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f2de55fd9322ded4e8e550631eb53734
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

View File

@ -1,168 +0,0 @@
fileFormatVersion: 2
guid: a5abb9cd3db4e694891b52a867a1707a
TextureImporter:
internalIDToNameTable:
- first:
213: -166343660977832731
second: f000_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 0
alignment: 9
spritePivot: {x: 0.5, y: 0.02037}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: f000_0
rect:
serializedVersion: 2
x: 19
y: 19
width: 532
height: 944
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 5ec37c8525701bdf0800000000000000
internalID: -166343660977832731
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,21 +0,0 @@
{
"fps": 1,
"ppu": 100,
"frame": [
570,
982
],
"pivot": [
0.5,
0.02037
],
"charHeightPx": 942,
"bodyHeightPx": 799,
"clips": [
{
"name": "front_idle",
"frames": 1,
"loop": true
}
]
}

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 48fa1fa1606e6d44e857c4e315d8ba96
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 0d68a9e892581594e939bb8bb78a3ac1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

View File

@ -1,168 +0,0 @@
fileFormatVersion: 2
guid: 1900b449bbfb58b4393d5665f5129b0c
TextureImporter:
internalIDToNameTable:
- first:
213: -166343660977832731
second: f000_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 0
alignment: 9
spritePivot: {x: 0.5, y: 0.02045}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: f000_0
rect:
serializedVersion: 2
x: 19
y: 19
width: 694
height: 940
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 5ec37c8525701bdf0800000000000000
internalID: -166343660977832731
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,21 +0,0 @@
{
"fps": 1,
"ppu": 100,
"frame": [
732,
978
],
"pivot": [
0.5,
0.02045
],
"charHeightPx": 938,
"bodyHeightPx": 877,
"clips": [
{
"name": "front_idle",
"frames": 1,
"loop": true
}
]
}

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: e2e0e727ef2627e4e95edf3bee7fc76b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: ab5e1fb35f54c734f9849c8cea6396b2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 2182604a7db43d840b09344b749496d4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

View File

@ -1,168 +0,0 @@
fileFormatVersion: 2
guid: cc7eaa04bc7977848bc4717a47ff8f4b
TextureImporter:
internalIDToNameTable:
- first:
213: -166343660977832731
second: f000_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 0
alignment: 9
spritePivot: {x: 0.5, y: 0.02075}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: f000_0
rect:
serializedVersion: 2
x: 19
y: 19
width: 525
height: 926
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 5ec37c8525701bdf0800000000000000
internalID: -166343660977832731
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,21 +0,0 @@
{
"fps": 1,
"ppu": 100,
"frame": [
563,
964
],
"pivot": [
0.5,
0.02075
],
"charHeightPx": 924,
"bodyHeightPx": 850,
"clips": [
{
"name": "front_idle",
"frames": 1,
"loop": true
}
]
}

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: a91577d55a26cf145bdb48cae6068b51
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: daf601eef2501824cba4fcf78852bed7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 70d931937e5258447851f43e7fc4429b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

View File

@ -1,168 +0,0 @@
fileFormatVersion: 2
guid: 8671e3ec0807b3340b1921dc2ed4a333
TextureImporter:
internalIDToNameTable:
- first:
213: -166343660977832731
second: f000_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 0
alignment: 9
spritePivot: {x: 0.5, y: 0.02121}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: f000_0
rect:
serializedVersion: 2
x: 19
y: 19
width: 474
height: 905
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 5ec37c8525701bdf0800000000000000
internalID: -166343660977832731
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,21 +0,0 @@
{
"fps": 1,
"ppu": 100,
"frame": [
512,
943
],
"pivot": [
0.5,
0.02121
],
"charHeightPx": 903,
"bodyHeightPx": 860,
"clips": [
{
"name": "front_idle",
"frames": 1,
"loop": true
}
]
}

View File

@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: c3f282f7fd7dedf4a9d91436db8ca357
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -84,8 +84,8 @@ namespace TKS.OutGame
{ {
public static readonly Vector2 CellSize = new Vector2(320f, 431f); public static readonly Vector2 CellSize = new Vector2(320f, 431f);
public static readonly Vector2 Spacing = new Vector2(22f, 20f); public static readonly Vector2 Spacing = new Vector2(22f, 20f);
/// <summary>일러 배율(원본 px → 화면 px · 전 유닛 공통) · 머리 끝 위치(카드 위에서 px).</summary> /// <summary>크기 표 size_px(머리 크기 기준 표준 키)가 카드에서 차지할 px — 배율 = UnitPx ÷ size_px(모든 유닛 머리 크기 같음) · 머리 끝 위치(카드 위에서 px).</summary>
const float ArtScale = 0.56f; const float UnitPx = 450f;
const float HeadTop = 50f; const float HeadTop = 50f;
public static Image Card(Transform content, OutGameSession s, HeroDb.Hero h, bool deployed, System.Action onTap) public static Image Card(Transform content, OutGameSession s, HeroDb.Hero h, bool deployed, System.Action onTap)
@ -107,17 +107,18 @@ namespace TKS.OutGame
var sp = UiKit.ArtSprite(h.art); var sp = UiKit.ArtSprite(h.art);
if (sp != null) if (sp != null)
{ {
// 모든 유닛 같은 배율(PD 2026-11-09 「유닛 크기를 동일한 비율로」) = 원본 px × ArtScale · 머리 끝(발 + bodyHeightPx · 무기 제외)을 카드 위 HeadTop 에 맞춤 // 머리 크기 통일(PD 2026-11-09 「캐릭터 크기가 동일하게 보이도록」) = 배율 UnitPx ÷ 크기 표 size_px(눈 간격 기준 · 말 탄 유닛은 탄 사람 기준) ·
// → 머리 크기가 같고 다리·말은 아래 띠 뒤로 잘림(레퍼런스 흉상 느낌) · 메타가 없으면 칸 맞춤 // 머리 끝(발 + bodyHeightPx · 무기 제외)을 카드 위 HeadTop 에 맞춤 → 다리·말은 아래 띠 뒤로 잘림(레퍼런스 흉상 느낌) · 메타가 없으면 칸 맞춤
var a = UnitArt.Load(h.art); var a = UnitArt.Load(h.art);
var m = a != null ? a.Meta : null; var m = a != null ? a.Meta : null;
float body = m != null ? (m.bodyHeightPx > 0.5f ? m.bodyHeightPx : m.charHeightPx) : 0f; float body = a != null ? a.BodyPx : 0f;
float size = a != null && a.SizePx > 0.5f ? a.SizePx : body;
Image art; Image art;
if (body > 0.5f && m.pivot != null && m.pivot.Length > 1) if (body > 0.5f && m.pivot != null && m.pivot.Length > 1)
{ {
float hgt = sp.rect.height; float hgt = sp.rect.height;
float headY = Mathf.Clamp01((m.pivot[1] * hgt + body) / hgt); // 머리 끝 = 프레임 아래에서의 비율 float headY = Mathf.Clamp01((m.pivot[1] * hgt + body) / hgt); // 머리 끝 = 프레임 아래에서의 비율
art = UiKit.Rect("Character", b, new Vector2(0.5f, 1f), new Vector2(0.5f, headY), new Vector2(0f, -HeadTop), sp.rect.size * ArtScale).gameObject.AddComponent<Image>(); art = UiKit.Rect("Character", b, new Vector2(0.5f, 1f), new Vector2(0.5f, headY), new Vector2(0f, -HeadTop), sp.rect.size * (UnitPx / size)).gameObject.AddComponent<Image>();
} }
else else
{ {

View File

@ -23,7 +23,7 @@ namespace TKS
public float ArcherMoveAllow; public float ArcherMoveAllow;
/// <summary>Resources/Units/&lt;ArtId&gt;/ 아트 폴더 · 비면 도형.</summary> /// <summary>Resources/Units/&lt;ArtId&gt;/ 아트 폴더 · 비면 도형.</summary>
public string ArtId = ""; public string ArtId = "";
/// <summary>아트 세로 크기(월드) = unitArtHeight × 반지름 배율.</summary> /// <summary>아트 머리 끝 높이(월드) — Init = 표준 키(unitArtHeight × 반지름 배율) · 아트가 있으면 UnitView 가 크기 표 배율 뒤 실제 머리 끝으로 바꾼다.</summary>
public float ArtHeight; public float ArtHeight;
/// <summary>이번 아군 턴에 행동을 마쳤는가.</summary> /// <summary>이번 아군 턴에 행동을 마쳤는가.</summary>

View File

@ -37,15 +37,25 @@ namespace TKS
public UnitArtMeta Meta; public UnitArtMeta Meta;
public readonly Dictionary<string, Sprite[]> Clips = new Dictionary<string, Sprite[]>(); public readonly Dictionary<string, Sprite[]> Clips = new Dictionary<string, Sprite[]>();
public readonly Dictionary<string, bool> Loop = new Dictionary<string, bool>(); public readonly Dictionary<string, bool> Loop = new Dictionary<string, bool>();
/// <summary>
/// 크기 기준 px = Resources/Units/art_size.csv 의 size_px(눈 간격 ÷ 기준 비율 = 머리 크기가 표준인 유닛의 키로 환산 · Tools/unit_art_size.py) ·
/// 0 = 표에 없음 → BodyPx. PD 2026-11-09 「캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」.
/// </summary>
public float SizePx;
/// <summary>캐릭터 몸 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx.</summary> /// <summary>그림 속 몸(머리 끝~발) 세로 px — bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx.</summary>
public float BodyPx
{
get { return Meta.bodyHeightPx > 0.5f ? Meta.bodyHeightPx : Meta.charHeightPx; }
}
/// <summary>배율 기준 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · 크기 표 size_px 우선(모든 유닛의 머리 크기가 같아짐), 없으면 BodyPx.</summary>
public float CharHeightWorld public float CharHeightWorld
{ {
get get
{ {
if (Meta.ppu <= 0.01f) return 1f; if (Meta.ppu <= 0.01f) return 1f;
float px = Meta.bodyHeightPx > 0.5f ? Meta.bodyHeightPx : Meta.charHeightPx; return (SizePx > 0.5f ? SizePx : BodyPx) / Meta.ppu;
return px / Meta.ppu;
} }
} }
} }
@ -54,7 +64,41 @@ namespace TKS
public static class UnitArt public static class UnitArt
{ {
public const string Root = "Units/"; public const string Root = "Units/";
/// <summary>크기 표(TextAsset · utf-8-sig CSV · 열 art · size_px) = Resources/Units/art_size.csv.</summary>
public const string SizeTable = "art_size";
static readonly Dictionary<string, UnitArtData> cache = new Dictionary<string, UnitArtData>(); static readonly Dictionary<string, UnitArtData> cache = new Dictionary<string, UnitArtData>();
static Dictionary<string, float> sizes;
/// <summary>크기 표의 size_px(없으면 0).</summary>
public static float SizeOf(string id)
{
if (sizes == null) sizes = ParseSizes(Resources.Load<TextAsset>(Root + SizeTable));
float v;
return id != null && sizes.TryGetValue(id, out v) ? v : 0f;
}
static Dictionary<string, float> ParseSizes(TextAsset ta)
{
var d = new Dictionary<string, float>();
if (ta == null) return d;
var lines = ta.text.TrimStart('').Split('\n');
if (lines.Length == 0) return d;
var head = new List<string>(lines[0].Trim().Split(','));
int ia = head.IndexOf("art"), isz = head.IndexOf("size_px");
if (ia < 0 || isz < 0)
{
Debug.LogWarning("[TKS] 유닛 크기 표 열(art · size_px) 없음");
return d;
}
for (int i = 1; i < lines.Length; i++)
{
var c = lines[i].Trim().Split(',');
float v;
if (c.Length > Mathf.Max(ia, isz) && float.TryParse(c[isz], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v) && v > 0f)
d[c[ia]] = v;
}
return d;
}
public static UnitArtData Load(string id) public static UnitArtData Load(string id)
{ {
@ -69,6 +113,7 @@ namespace TKS
public static void ClearCache() public static void ClearCache()
{ {
cache.Clear(); cache.Clear();
sizes = null;
} }
static UnitArtData LoadUncached(string id) static UnitArtData LoadUncached(string id)
@ -87,7 +132,7 @@ namespace TKS
} }
if (meta == null || meta.clips == null) return null; if (meta == null || meta.clips == null) return null;
var d = new UnitArtData { Id = id, Meta = meta }; var d = new UnitArtData { Id = id, Meta = meta, SizePx = SizeOf(id) };
foreach (var c in meta.clips) foreach (var c in meta.clips)
{ {
if (c == null || string.IsNullOrEmpty(c.name)) continue; if (c == null || string.IsNullOrEmpty(c.name)) continue;
@ -134,6 +179,10 @@ namespace TKS
get { return data; } get { return data; }
} }
/// <summary>그려진 머리 끝 높이(월드 · 발 기준) = BodyPx × 배율 — 크기 표로 커진 기마 유닛은 표준 키보다 높다.</summary>
public float TopHeight { get; private set; }
/// <param name="worldHeight">표준 키(월드) — 크기 표 size_px 가 이 높이가 되도록 배율(= 머리 크기 통일).</param>
public void Setup(UnitArtData d, float worldHeight, int sortingOrder) public void Setup(UnitArtData d, float worldHeight, int sortingOrder)
{ {
data = d; data = d;
@ -143,6 +192,7 @@ namespace TKS
float h = d.CharHeightWorld; float h = d.CharHeightWorld;
float s = h > 0.001f ? worldHeight / h : 1f; float s = h > 0.001f ? worldHeight / h : 1f;
transform.localScale = new Vector3(s, s, 1f); transform.localScale = new Vector3(s, s, 1f);
TopHeight = d.Meta.ppu > 0.01f ? d.BodyPx / d.Meta.ppu * s : worldHeight;
} }
public bool Has(string clip) public bool Has(string clip)

View File

@ -140,6 +140,7 @@ namespace TKS
ago.transform.localPosition = new Vector3(0f, -u.Radius * 0.55f, 0f); ago.transform.localPosition = new Vector3(0f, -u.Radius * 0.55f, 0f);
anim = ago.AddComponent<FrameAnimator>(); anim = ago.AddComponent<FrameAnimator>();
anim.Setup(art, u.ArtHeight, 0); anim.Setup(art, u.ArtHeight, 0);
u.ArtHeight = anim.TopHeight; // 크기 표 배율 뒤 실제 머리 끝(HP 바 · 머리 위 표시 기준)
anim.Renderer.flipX = facingLeft; anim.Renderer.flipX = facingLeft;
anim.SetIdle(ClipName("idle")); anim.SetIdle(ClipName("idle"));
anim.PlayIdle(); anim.PlayIdle();

View File

@ -975,6 +975,49 @@ namespace TKS.Tests
Assert.IsTrue(u.View.Anim.Renderer.flipX, "왼쪽 = 좌우 반전"); Assert.IsTrue(u.View.Anim.Renderer.flipX, "왼쪽 = 좌우 반전");
} }
[UnityTest]
public IEnumerator UnitArt_SizeTable_SameHeadSize_MountedRiderLikeFoot()
{
// PD 2026-11-09 「캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」 — 크기 표(art_size.csv · 눈 간격 기준)로 배율을 정하면
// 게임 안 눈 간격(월드)이 모든 유닛에서 같다 · 기마 유닛은 표준 키보다 커진다(탄 사람 = 보병 크기)
var ta = Resources.Load<TextAsset>(UnitArt.Root + UnitArt.SizeTable);
Assert.IsNotNull(ta, "크기 표 Resources/Units/art_size.csv");
var lines = ta.text.TrimStart('').Split('\n');
var head = new List<string>(lines[0].Trim().Split(','));
int ia = head.IndexOf("art"), im = head.IndexOf("mounted"), ie = head.IndexOf("eye_px");
var eyes = new List<float>();
int mounted = 0;
var go = new GameObject("SizeProbe");
var fa = go.AddComponent<FrameAnimator>();
for (int i = 1; i < lines.Length; i++)
{
var c = lines[i].Trim().Split(',');
if (c.Length <= ie) continue;
var a = UnitArt.Load(c[ia]);
Assert.IsNotNull(a, "표의 아트 " + c[ia]);
Assert.Greater(a.SizePx, 0f, c[ia] + " size_px");
fa.Setup(a, cfg.unitArtHeight, 0);
eyes.Add(float.Parse(c[ie], System.Globalization.CultureInfo.InvariantCulture) / a.Meta.ppu * go.transform.localScale.x);
if (c[im] == "1")
{
mounted++;
Assert.Greater(fa.TopHeight, cfg.unitArtHeight * 1.2f, c[ia] + " 기마 = 탄 사람이 보병 크기가 되도록 커짐");
}
}
Object.Destroy(go);
Assert.GreaterOrEqual(eyes.Count, 15, "표 행 수");
Assert.GreaterOrEqual(mounted, 5, "기마 행 수");
eyes.Sort();
Assert.Less(eyes[eyes.Count - 1] / eyes[0], 1.01f, "모든 유닛 눈 간격(월드) 같음");
// 배치된 기마 영웅 = HP 바 기준(ArtHeight)이 실제 머리 끝
yield return Fresh();
var u = bm.SpawnUnit(UnitType.Cavalry, Faction.Player, new Vector2(0f, -4f), "조운", 1f, 1f, false, "zhaoyun");
yield return null;
Assert.AreEqual(u.View.Anim.TopHeight, u.ArtHeight, 1e-4f, "ArtHeight = 그려진 머리 끝");
Assert.Greater(u.ArtHeight, cfg.unitArtHeight, "기마 = 표준 키보다 높음");
}
// ---------- 스테이지 표(Assets/Data/Stages/*.csv · 145 스테이지 · 2026-11-05 #1086) ---------- // ---------- 스테이지 표(Assets/Data/Stages/*.csv · 145 스테이지 · 2026-11-05 #1086) ----------
static StageTable.Tables ReadStageTable() static StageTable.Tables ReadStageTable()

149
Tools/unit_art_size.py Normal file
View File

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""유닛 아트 크기 표 생성(PD 2026-11-09 「영웅 목록 캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」).
기준 = 눈 간격(머리 크기) — 투구·수염·말과 상관없이 같은 화풍이면 머리 크기에 비례한다.
eye_px = 대기 첫 프레임(front_idle/f000.png)의 두 눈 중심 사이 가로 px(얼굴 피부 덩어리 안 어두운 구멍 2개)
ref = 말을 타지 않은 유닛(mounted=0)의 eye_px / body_px 중앙값
size_px = eye_px / ref = 「머리 크기가 표준인 유닛의 키」로 환산한 세로 px
게임(FrameAnimator)·영웅 목록은 bodyHeightPx 대신 size_px 로 배율을 정한다 → 모든 유닛의 머리 크기가 같아지고,
말 탄 유닛은 탄 사람이 보병과 같은 크기가 되도록 커진다.
표 = Assets/Resources/Units/art_size.csv(utf-8-sig · SOT) · mounted 열만 사람이 고친다(새 아트 = 이름에 cavalry 면 1).
눈을 못 찾으면 size_px 를 비워 둔다(게임은 bodyHeightPx 로 대체) · 실행 결과를 --sheet 시트로 눈 위치를 확인할 것.
사용: python -I -B -X utf8 Tools/unit_art_size.py <프로젝트 루트> [--sheet out.png]
"""
import csv
import glob
import json
import os
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy import ndimage
TABLE = 'Assets/Resources/Units/art_size.csv'
FIELDS = ['art', 'mounted', 'eye_px', 'body_px', 'size_px', 'note']
def skin_mask(a, red):
r, g, b, al = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
mx = np.maximum(np.maximum(r, g), b)
mn = np.minimum(np.minimum(r, g), b)
sat = np.where(mx > 0, (mx - mn) / np.maximum(mx, 1e-6), 0)
if red: # 붉은 얼굴(관우)
sk = (al > 0.9) & (r > 0.8) & (g > 0.3) & (g < 0.6) & (b > 0.25) & (b < 0.55)
else:
sk = (al > 0.9) & (r >= g) & (g >= b - 0.02) & (mx > 0.78) & (sat > 0.06) & (sat < 0.45) & ((r - b) > 0.06)
return sk, mx
def find_eyes(img):
"""두 눈 (왼쪽, 오른쪽) = (면적, x, y) · 못 찾으면 None."""
a = np.asarray(img.convert('RGBA')).astype(np.float32) / 255.0
for red in (False, True):
sk, val = skin_mask(a, red)
lab, n = ndimage.label(sk)
if not n:
continue
sizes = ndimage.sum(sk, lab, range(1, n + 1))
for k in list(np.argsort(sizes)[::-1][:6] + 1):
face = lab == k
ys, xs = np.where(face)
if len(ys) < 3000:
continue
fh = ys.max() - ys.min() + 1
holes = ndimage.binary_fill_holes(face) & ~face
hl, hn = ndimage.label(holes)
cand = []
for j in range(1, hn + 1):
yy, xx = np.where(hl == j)
if len(yy) < 150 or val[yy, xx].mean() > 0.4:
continue
if (xx.max() - xx.min() + 1) > 2.2 * (yy.max() - yy.min() + 1):
continue # 가로로 긴 구멍 = 수염·입
cand.append((len(yy), xx.mean(), yy.mean()))
best = None
for l in cand:
for r in cand:
if l[1] >= r[1] or abs(l[2] - r[2]) > 0.12 * fh:
continue
if min(l[0], r[0]) < 0.45 * max(l[0], r[0]):
continue
score = min(l[0], r[0]) * (r[1] - l[1])
if best is None or score > best[0]:
best = (score, l, r)
if best:
return best[1], best[2]
return None
def main():
root = sys.argv[1]
sheet_path = sys.argv[sys.argv.index('--sheet') + 1] if '--sheet' in sys.argv else None
table = os.path.join(root, TABLE)
old = {}
if os.path.exists(table):
with open(table, encoding='utf-8-sig', newline='') as f:
old = {r['art']: r for r in csv.DictReader(f)}
rows, shots = [], []
for meta_path in sorted(glob.glob(os.path.join(root, 'Assets/Resources/Units/*/meta.json'))):
folder = os.path.dirname(meta_path)
art = os.path.basename(folder)
meta = json.load(open(meta_path, encoding='utf-8'))
body = meta.get('bodyHeightPx') or meta.get('charHeightPx') or 0
frame = os.path.join(folder, 'front_idle', 'f000.png')
if not os.path.exists(frame):
frame = sorted(glob.glob(os.path.join(folder, '*', 'f000.png')))[0]
img = Image.open(frame).convert('RGBA')
eyes = find_eyes(img)
prev = old.get(art, {})
mounted = prev.get('mounted', '1' if 'cavalry' in art else '0')
eye = round(eyes[1][1] - eyes[0][1], 1) if eyes else 0
rows.append({'art': art, 'mounted': mounted, 'eye_px': eye, 'body_px': body, 'size_px': '', 'note': prev.get('note', '')})
shots.append((art, meta, img, eyes))
foot = sorted(r['eye_px'] / r['body_px'] for r in rows if r['mounted'] == '0' and r['eye_px'] and r['body_px'])
if not foot:
sys.exit('말을 타지 않은 유닛의 눈 간격을 하나도 못 찾음')
mid = len(foot) // 2
ref = foot[mid] if len(foot) % 2 else (foot[mid - 1] + foot[mid]) / 2
for r in rows:
r['size_px'] = round(r['eye_px'] / ref, 1) if r['eye_px'] else ''
with open(table, 'w', encoding='utf-8-sig', newline='') as f:
w = csv.DictWriter(f, fieldnames=FIELDS, lineterminator='\n')
w.writeheader()
w.writerows(rows)
print('ref(눈 간격 / 키 · 말 안 탄 %d종 중앙값) = %.4f' % (len(foot), ref))
for r in rows:
s = (r['size_px'] / r['body_px']) if r['size_px'] else 0
print('%-22s mounted %s eye %6s body %5s size %7s 배율 x%.2f%s' % (
r['art'], r['mounted'], r['eye_px'], r['body_px'], r['size_px'], 1 / s if s else 0, '' if r['eye_px'] else ' ← 눈 못 찾음(bodyHeightPx 대체)'))
if sheet_path: # 확인 시트: 표 배율로 그린 대기 그림 + 눈 표시(머리 크기가 같아 보여야 함)
unit = 160.0 # 표준 키 = 시트 160 px
cols, cw, ch = 9, 220, 330
sheet = Image.new('RGB', (cw * cols, ch * ((len(shots) + cols - 1) // cols)), (40, 44, 60))
dr = ImageDraw.Draw(sheet)
font = ImageFont.truetype('C:/Windows/Fonts/malgun.ttf', 13)
for i, ((art, meta, img, eyes), r) in enumerate(zip(shots, rows)):
size = r['size_px'] or r['body_px']
sc = unit / size
x0, y0 = (i % cols) * cw, (i // cols) * ch
t = img.resize((max(1, int(img.width * sc)), max(1, int(img.height * sc))), Image.LANCZOS)
base = y0 + ch - 22
ox, oy = x0 + cw // 2 - t.width // 2, int(base - (1 - meta['pivot'][1]) * img.height * sc)
sheet.paste(t, (ox, oy), t)
for e in eyes or ():
ex, ey = ox + e[1] * sc, oy + e[2] * sc
dr.ellipse([ex - 3, ey - 3, ex + 3, ey + 3], outline=(0, 255, 0), width=2)
dr.text((x0 + 3, base + 3), '%s%s' % (art, ' (말)' if r['mounted'] == '1' else ''), fill=(255, 255, 255), font=font)
sheet.save(sheet_path)
print('sheet', sheet_path)
if __name__ == '__main__':
main()