chore: R.py 构建工具支持新图片类型

This commit is contained in:
XcantloadX 2025-02-04 13:03:22 +08:00
parent 5a200f81d0
commit 1201fc7403
5 changed files with 305 additions and 102 deletions

2
.gitignore vendored
View File

@ -9,6 +9,8 @@ config.json
reports/
invoke.yml
pyproject.toml
tmp/
res/sprites_compiled/
##########################
# Byte-compiled / optimized / DLL files

View File

@ -1 +1,2 @@
graft res
graft res
prune res/sprites

View File

@ -1,4 +1,5 @@
-r requirements.txt
jinja2==3.1.5
pyinstaller==6.11.1
twine==6.1.0
twine==6.1.0
dataclasses-json==0.6.7

View File

@ -4,37 +4,28 @@
from kotonebot.backend.util import res_path
from kotonebot.backend.core import image
{% for lang in data -%}
{%- for class_name, attrs in lang.resources.items() -%}
class {{ class_name }}:
{%- for attr in attrs.values() %}
# {{ attr }}
{% if attr.type == 'image' -%}
{% macro render_class_attributes(class) -%}
{%- for attr in class.attributes -%}
{%- if attr.type == 'image' %}
{{ attr.name }} = {{ attr.value }}
"""
路径:{{ attr.rel_path }}<br>
模块:`{{ attr.class_path|join('.') }}.{{ attr.name }}`<br>
<img src="vscode-file://vscode-app/{{ attr.abspath }}" style="max-width: 70%; max-height: 200px;">
{%- for line in attr.docstring.split('\n') %}
{{ line }}
{%- endfor -%}
"""
{%- elif attr.type == 'next_class' -%}
{{ attr.name }} = {{ attr.value }}
{%- endif -%}
{% endfor %}
{% elif attr.type == 'class' %}
class {{ attr.name }}:
{{ render_class_attributes(attr) | indent(4) }}
pass
{%- else %}
{{ attr.name }} = {{ attr.name }}
{%- endif %}
{%- endfor %}
{% endmacro -%}
{%- for class in data %}
class {{ class.name }}:
{{ render_class_attributes(class) }}
pass
{% endfor %}
{# class {{ lang.language }}:
# {% for attr in lang.attrs %}
# {{ attr.name }} = os.path.abspath("{{ attr.path }}")
# """
# 路径:`{{ attr.path }}`<br>
# 模块:`{{ attr.class_path|join('.') }}.{{ attr.name }}`<br>
# <img src="vscode-file://vscode-app/{{ attr.abspath }}" style="max-width: 200px;">
# """
# {% endfor %}
#}
{% endfor %}
{%- endfor -%}

View File

@ -2,85 +2,293 @@
from genericpath import isfile
import os
from typing import Any
import shutil
import uuid
import jinja2
PATH = './res/sprites'
from typing import Any, TypeGuard, Literal, Union, cast
from dataclasses import dataclass
from dataclasses_json import dataclass_json, DataClassJsonMixin
import cv2
from cv2.typing import MatLike
PATH = '.\\res\\sprites'
SpriteType = Literal['basic', 'metadata']
@dataclass
class Sprite(DataClassJsonMixin):
"""表示一个精灵图像资源及其元数据。"""
type: SpriteType
uuid: str
"""编译后的 UUID。作为图片名称。"""
name: str
"""在 R.py 的类中出现的属性名称"""
display_name: str
"""在调试中显示的名称"""
class_path: list[str]
"""
R.py 的类中出现的路径
例如 ['Common', 'Button'] 会变为 Common.Button
不包括属性名
"""
rel_path: str
"""相对于项目根目录的路径。用于调试信息的显示"""
abs_path: str
"""sprite 图片的绝对路径"""
origin_file: str
"""原始图片的绝对路径"""
@dataclass
class RectPoints(DataClassJsonMixin):
"""表示一个矩形的两个对角点坐标"""
x1: float
y1: float
x2: float
y2: float
@dataclass
class Point(DataClassJsonMixin):
"""表示一个二维坐标点"""
x: float
y: float
@dataclass
class Annotation(DataClassJsonMixin):
"""图像标注数据"""
id: str
type: Literal['rect']
data: RectPoints
tip: Union[str, None]
@dataclass
class Definition(DataClassJsonMixin):
"""资源定义基类"""
name: str
"""在 R.py 的类中出现的属性名称"""
displayName: str
"""在调试器与调试输出中的名称"""
type: Literal['template', 'ocr', 'color', 'hint-box']
"""标注类型"""
annotationId: str
"""标注 ID"""
class TemplateDefinition(Definition):
"""模板匹配类型的资源定义"""
useHintRect: bool
"""
是否将这个模板的矩形范围作为运行时执行模板寻找函数时的提示范围
若为 true则运行时会先在这个范围内寻找如果没找到再在整张截图中寻找
"""
@dataclass
class SpriteMetadata(DataClassJsonMixin):
"""Sprite 元数据,包含标注和定义信息"""
annotations: list[Annotation]
definitions: dict[str, Definition]
@dataclass
class OutputClass(DataClassJsonMixin):
"""输出类定义"""
type: Literal['class']
name: str
attributes: 'list[OutputClass | ImageAttribute]'
@dataclass
class ImageAttribute(DataClassJsonMixin):
"""图像属性定义"""
type: Literal['image']
name: str
docstring: str
value: str
def to_camel_case(s: str) -> str:
return ''.join(word.capitalize() for word in s.split('_'))
def main():
data = []
# 列出所有语言
languages = os.listdir(PATH)
def to_camel_cases(arr: list[str]) -> list[str]:
return [to_camel_case(s) for s in arr]
print('Languages:', languages)
# 扫描资源
for language in languages:
print(f'Scanning {language}...')
lang_data = {
'language': language,
'resources': {}
}
# 列出所有资源
resources = {}
def list_dir(dir_path: str, parent_class: dict[str, Any], class_path: list[str]):
for current in os.listdir(dir_path):
path = os.path.join(dir_path, current)
if os.path.isfile(path):
if not path.endswith('.png') and not path.endswith('.jpg'):
continue
attr_name = to_camel_case(current.replace('.png', '').replace('.jpg', ''))
rel_path = os.path.join(dir_path, current).replace('\\', '/')
abs_path = os.path.abspath(os.path.join(dir_path, current))
print(current, attr_name)
parent_class[attr_name] = {
'type': 'image',
'value': f'image(res_path("{rel_path}"))',
'name': attr_name,
'abspath': abs_path.replace('\\', '/'),
'class_path': class_path,
'rel_path': rel_path,
}
elif os.path.isdir(path):
class_name = to_camel_case(current)
resources[class_name] = {
# 'type': 'class',
# 'attrs': {},
}
parent_class[class_name] = {
'type': 'next_class',
'name': class_name,
'value': f'{class_name}',
}
list_dir(path, resources[class_name], class_path + [class_name])
def escape(s: str) -> str:
return s.replace('\\', '\\\\')
list_dir(os.path.join(PATH, language), {}, [])
# resources 结构
"""
{
"<类名1>": {
"<属性1>": { ... },
"<属性2>": { ... },
...
},
"<类名2>": {
"<属性1>": { ... },
"<属性2>": { ... },
...
},
...
}
"""
resources = {k: v for k, v in reversed(resources.items())}
lang_data['resources'] = resources
data.append(lang_data)
def unify_path(path: str) -> str:
return path.replace('/', '\\')
# 渲染模板
template = jinja2.Template(open('./tools/R.jinja2', encoding='utf-8').read())
with open('./kotonebot/tasks/R.py', 'w', encoding='utf-8') as f:
f.write(template.render(data=data))
def scan_png_files(path: str) -> list[str]:
"""扫描所有 PNG 文件"""
png_files = []
for root, _, files in os.walk(path):
for file in files:
if file.endswith('.png'):
file_path = os.path.join(root, file)
png_files.append(file_path)
return png_files
def load_and_copy_meta_data_sprite(root_path: str, png_file: str) -> list[Sprite]:
"""加载 metadata 类型的 sprite"""
json_path = png_file + '.json'
with open(json_path, 'r', encoding='utf-8') as f:
metadata = SpriteMetadata.from_json(f.read()) # 使用 dataclass-json 的解析方法
# 裁剪并保存图片
clips: dict[str, str] = {} # id -> 文件路径
image = cv2.imread(png_file)
for annotation in metadata.annotations:
if annotation.type == 'rect':
rect = annotation.data
x1, y1, x2, y2 = rect.x1, rect.y1, rect.x2, rect.y2
clip = image[int(y1):int(y2), int(x1):int(x2)]
# 保存图片
if not os.path.exists('tmp'):
os.makedirs('tmp')
path = os.path.join('tmp', f'{annotation.id}.png')
cv2.imwrite(path, clip)
clips[annotation.id] = path
# 关联 Definition创建 Sprite
sprites: list[Sprite] = []
for definition in metadata.definitions.values():
if definition.type != 'template':
continue
sprites.append(Sprite(
type='metadata',
uuid=definition.annotationId,
name=to_camel_case(definition.name.split('.')[-1]),
display_name=definition.displayName,
class_path=to_camel_cases(definition.name.split('.')[:-1]),
rel_path=png_file,
abs_path=os.path.abspath(clips[definition.annotationId]),
origin_file=os.path.abspath(png_file),
))
return sprites
def load_basic_sprite(root_path: str, png_file: str) -> Sprite:
"""加载 basic 类型的 sprite"""
file_name = os.path.basename(png_file)
class_path = os.path.relpath(os.path.dirname(png_file), root_path).split(os.sep)
class_path = [to_camel_case(c) for c in class_path]
return Sprite(
type='basic',
uuid=str(uuid.uuid4()),
name=to_camel_case(file_name.replace('.png', '')),
display_name=file_name,
class_path=class_path,
rel_path=png_file,
abs_path=os.path.abspath(png_file),
origin_file=os.path.abspath(png_file)
)
def load_sprites(root_path: str, png_files: list[str]) -> list[Sprite]:
""""""
sprites = []
for file in png_files:
# 判断类型
json_path = file + '.json'
if os.path.exists(json_path):
sprites.extend(load_and_copy_meta_data_sprite(root_path, file))
else:
# continue
sprites.append(load_basic_sprite(root_path, file))
return sprites
def make_classes(sprites: list[Sprite], output_path: str) -> list[OutputClass]:
"""根据 Sprite 数据生成 R.py 中的类信息。"""
# 按照 class_path 对 sprites 进行分组
class_map: dict[str, OutputClass] = {}
# 创建或获取指定路径的类
def get_or_create_class(path: list[str]) -> Union[OutputClass, None]:
if not path:
return None
class_key = '.'.join(path)
if class_key in class_map:
return class_map[class_key]
new_class = OutputClass(
type='class',
name=path[-1],
attributes=[]
)
class_map[class_key] = new_class
return new_class
# 处理每个 sprite
for sprite in sprites:
# 获取当前 sprite 的完整路径
class_path = sprite.class_path
# 创建或获取所有父类
current_class = None
for i in range(len(class_path)):
path = class_path[:i + 1]
cls = get_or_create_class(path)
if not cls:
continue
# 如果这个类还没有被添加到父类的属性中,添加它
if i > 0:
parent = get_or_create_class(path[:-1])
if parent and not any(isinstance(attr, OutputClass) and attr.name == cls.name for attr in parent.attributes):
parent.attributes.append(cls)
current_class = cls
# 将 sprite 添加为最后一级类的属性
if current_class:
# 创建图片属性
docstring = (
f"名称:{sprite.display_name}\\n\n"
f"路径:{escape(sprite.rel_path)}\\n\n"
f"模块:`{'.'.join(sprite.class_path)}`\\n\n"
f'<img src="vscode-file://vscode-app/{escape(sprite.abs_path)}" title="{sprite.display_name}" />\\n\n'
)
if sprite.type == 'metadata':
docstring += (
f"原始文件:\\n\n"
f"<img src='vscode-file://vscode-app/{escape(sprite.origin_file)}' title='原始文件' width='80%' />"
)
img_attr = ImageAttribute(
type='image',
name=sprite.name,
docstring=docstring,
value=f'image(res_path(r"{output_path}\\{sprite.uuid}.png"))'
)
current_class.attributes.append(img_attr)
# 返回顶层类列表
return [cls for (path, cls) in class_map.items() if path.find('.') == -1]
def copy_sprites(sprites: list[Sprite], output_folder: str) -> list[Sprite]:
"""复制 sprites 图片到目标路径,并输出 R.py"""
if not os.path.exists(output_folder):
os.makedirs(output_folder)
for sprite in sprites:
src_img_path = sprite.abs_path
img_name = sprite.uuid + '.png'
dst_img_path = os.path.join(output_folder, img_name)
shutil.copy(src_img_path, dst_img_path)
sprite.abs_path = os.path.abspath(dst_img_path)
return sprites
def indent(text: str, indent: int = 4) -> str:
"""调整文本的缩进"""
lines = text.split('\n')
return '\n'.join(' ' * indent + line for line in lines)
if __name__ == '__main__':
main()
if os.path.exists('res\\sprites_compiled'):
shutil.rmtree('res\\sprites_compiled')
path = PATH + '\\jp'
files = scan_png_files(path)
sprites = load_sprites(path, files)
sprites = copy_sprites(sprites, 'res\\sprites_compiled')
classes = make_classes(sprites, 'res\\sprites_compiled')
env = jinja2.Environment(loader=jinja2.FileSystemLoader('./tools'))
env.filters['indent'] = indent
template = env.get_template('R.jinja2')
with open('./kotonebot/tasks/R.py', 'w', encoding='utf-8') as f:
f.write(template.render(data=classes))