refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
239
packages/engine/asset-system/src/utils/AssetCollector.ts
Normal file
239
packages/engine/asset-system/src/utils/AssetCollector.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 通用资产收集器
|
||||
* Generic Asset Collector
|
||||
*
|
||||
* 从序列化的场景数据中自动收集资产引用。
|
||||
* 支持基于字段名模式和 Property 元数据两种识别方式。
|
||||
*
|
||||
* Automatically collects asset references from serialized scene data.
|
||||
* Supports both field name pattern matching and Property metadata recognition.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 场景资产引用信息(用于构建时收集)
|
||||
* Scene asset reference info (for build-time collection)
|
||||
*/
|
||||
export interface SceneAssetRef {
|
||||
/** 资产 GUID | Asset GUID */
|
||||
guid: string;
|
||||
/** 来源组件类型 | Source component type */
|
||||
componentType: string;
|
||||
/** 来源字段名 | Source field name */
|
||||
fieldName: string;
|
||||
/** 实体名称(可选)| Entity name (optional) */
|
||||
entityName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产字段模式配置
|
||||
* Asset field pattern configuration
|
||||
*/
|
||||
export interface AssetFieldPattern {
|
||||
/** 字段名模式(正则表达式)| Field name pattern (regex) */
|
||||
pattern: RegExp;
|
||||
/** 字段类型(用于分类)| Field type (for categorization) */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认资产字段模式
|
||||
* Default asset field patterns
|
||||
*
|
||||
* 这些模式用于识别常见的资产引用字段
|
||||
* These patterns are used to identify common asset reference fields
|
||||
*/
|
||||
export const DEFAULT_ASSET_PATTERNS: AssetFieldPattern[] = [
|
||||
// GUID 类字段 | GUID-like fields
|
||||
{ pattern: /^.*[Gg]uid$/, type: 'guid' },
|
||||
{ pattern: /^.*[Aa]sset[Ii]d$/, type: 'guid' },
|
||||
{ pattern: /^.*[Aa]ssetGuid$/, type: 'guid' },
|
||||
|
||||
// 纹理/贴图字段 | Texture fields
|
||||
{ pattern: /^texture$/, type: 'texture' },
|
||||
{ pattern: /^.*[Tt]exture[Pp]ath$/, type: 'texture' },
|
||||
|
||||
// 音频字段 | Audio fields
|
||||
{ pattern: /^clip$/, type: 'audio' },
|
||||
{ pattern: /^.*[Aa]udio[Pp]ath$/, type: 'audio' },
|
||||
|
||||
// 通用路径字段 | Generic path fields
|
||||
{ pattern: /^.*[Pp]ath$/, type: 'path' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 检查值是否像 GUID
|
||||
* Check if value looks like a GUID
|
||||
*/
|
||||
function isGuidLike(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
// GUID 格式: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// 或者简单的包含连字符的长字符串
|
||||
return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value) ||
|
||||
(value.includes('-') && value.length >= 30 && value.length <= 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从组件数据中收集资产引用
|
||||
* Collect asset references from component data
|
||||
*/
|
||||
function collectFromComponentData(
|
||||
componentType: string,
|
||||
data: Record<string, unknown>,
|
||||
patterns: AssetFieldPattern[],
|
||||
entityName?: string
|
||||
): SceneAssetRef[] {
|
||||
const references: SceneAssetRef[] = [];
|
||||
|
||||
for (const [fieldName, value] of Object.entries(data)) {
|
||||
// 检查是否匹配任何资产字段模式
|
||||
// Check if matches any asset field pattern
|
||||
const matchesPattern = patterns.some(p => p.pattern.test(fieldName));
|
||||
|
||||
if (matchesPattern) {
|
||||
// 处理单个值 | Handle single value
|
||||
if (isGuidLike(value)) {
|
||||
references.push({
|
||||
guid: value,
|
||||
componentType,
|
||||
fieldName,
|
||||
entityName
|
||||
});
|
||||
}
|
||||
// 处理数组 | Handle array
|
||||
else if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (isGuidLike(item)) {
|
||||
references.push({
|
||||
guid: item,
|
||||
componentType,
|
||||
fieldName,
|
||||
entityName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊处理已知的数组字段(如 particleAssets)
|
||||
// Special handling for known array fields (like particleAssets)
|
||||
if (fieldName === 'particleAssets' && Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (isGuidLike(item)) {
|
||||
references.push({
|
||||
guid: item,
|
||||
componentType,
|
||||
fieldName,
|
||||
entityName
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体类型定义(支持嵌套 children)
|
||||
* Entity type definition (supports nested children)
|
||||
*/
|
||||
interface EntityData {
|
||||
name?: string;
|
||||
components?: Array<{ type: string; data?: Record<string, unknown> }>;
|
||||
children?: EntityData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归处理实体及其子实体
|
||||
* Recursively process entity and its children
|
||||
*/
|
||||
function collectFromEntity(
|
||||
entity: EntityData,
|
||||
patterns: AssetFieldPattern[],
|
||||
references: SceneAssetRef[]
|
||||
): void {
|
||||
const entityName = entity.name;
|
||||
|
||||
// 处理当前实体的组件 | Process current entity's components
|
||||
if (entity.components) {
|
||||
for (const component of entity.components) {
|
||||
if (!component.data) continue;
|
||||
|
||||
const componentRefs = collectFromComponentData(
|
||||
component.type,
|
||||
component.data,
|
||||
patterns,
|
||||
entityName
|
||||
);
|
||||
|
||||
references.push(...componentRefs);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子实体 | Recursively process children
|
||||
if (entity.children && Array.isArray(entity.children)) {
|
||||
for (const child of entity.children) {
|
||||
collectFromEntity(child, patterns, references);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列化的场景数据中收集所有资产引用
|
||||
* Collect all asset references from serialized scene data
|
||||
*
|
||||
* @param sceneData 序列化的场景数据(JSON 对象)| Serialized scene data (JSON object)
|
||||
* @param patterns 资产字段模式(可选,默认使用内置模式)| Asset field patterns (optional, defaults to built-in patterns)
|
||||
* @returns 资产引用列表 | List of asset references
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sceneData = JSON.parse(sceneJson);
|
||||
* const references = collectAssetReferences(sceneData);
|
||||
* for (const ref of references) {
|
||||
* console.log(`Found asset ${ref.guid} in ${ref.componentType}.${ref.fieldName}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function collectAssetReferences(
|
||||
sceneData: { entities?: EntityData[] },
|
||||
patterns: AssetFieldPattern[] = DEFAULT_ASSET_PATTERNS
|
||||
): SceneAssetRef[] {
|
||||
const references: SceneAssetRef[] = [];
|
||||
|
||||
if (!sceneData.entities) {
|
||||
return references;
|
||||
}
|
||||
|
||||
// 遍历顶层实体,递归处理嵌套的子实体
|
||||
// Iterate top-level entities, recursively process nested children
|
||||
for (const entity of sceneData.entities) {
|
||||
collectFromEntity(entity, patterns, references);
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资产引用列表中提取唯一的 GUID 集合
|
||||
* Extract unique GUID set from asset references
|
||||
*/
|
||||
export function extractUniqueGuids(references: SceneAssetRef[]): Set<string> {
|
||||
return new Set(references.map(ref => ref.guid));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按组件类型分组资产引用
|
||||
* Group asset references by component type
|
||||
*/
|
||||
export function groupByComponentType(references: SceneAssetRef[]): Map<string, SceneAssetRef[]> {
|
||||
const groups = new Map<string, SceneAssetRef[]>();
|
||||
|
||||
for (const ref of references) {
|
||||
const existing = groups.get(ref.componentType) || [];
|
||||
existing.push(ref);
|
||||
groups.set(ref.componentType, existing);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
79
packages/engine/asset-system/src/utils/AssetUtils.ts
Normal file
79
packages/engine/asset-system/src/utils/AssetUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Asset Utilities
|
||||
* 资产工具函数
|
||||
*
|
||||
* Provides common utilities for asset management:
|
||||
* - GUID validation and generation (re-exported from core)
|
||||
* - Content hashing
|
||||
* 提供资产管理的通用工具:
|
||||
* - GUID 验证和生成(从 core 重导出)
|
||||
* - 内容哈希
|
||||
*/
|
||||
|
||||
// Re-export GUID utilities from core (single source of truth)
|
||||
// 从 core 重导出 GUID 工具(单一来源)
|
||||
export { generateGUID, isValidGUID } from '@esengine/ecs-framework';
|
||||
|
||||
// ============================================================================
|
||||
// Hash Utilities
|
||||
// 哈希工具
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of an ArrayBuffer
|
||||
* 计算 ArrayBuffer 的 SHA-256 哈希
|
||||
*
|
||||
* Returns first 16 hex characters of the hash.
|
||||
* 返回哈希的前 16 个十六进制字符。
|
||||
*
|
||||
* @param buffer - The buffer to hash
|
||||
* @returns Hash string (16 hex characters)
|
||||
*/
|
||||
export async function hashBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
// Use Web Crypto API if available
|
||||
// 如果可用则使用 Web Crypto API
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
||||
}
|
||||
|
||||
// Fallback: simple DJB2 hash
|
||||
// 回退:简单的 DJB2 哈希
|
||||
const view = new Uint8Array(buffer);
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ view[i];
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute hash of a string
|
||||
* 计算字符串的哈希
|
||||
*
|
||||
* @param str - The string to hash
|
||||
* @returns Hash string (8 hex characters)
|
||||
*/
|
||||
export function hashString(str: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute content hash from file path and size
|
||||
* 从文件路径和大小计算内容哈希
|
||||
*
|
||||
* This is a lightweight hash for quick comparison, not cryptographically secure.
|
||||
* 这是一个用于快速比较的轻量级哈希,不具有加密安全性。
|
||||
*
|
||||
* @param path - File path
|
||||
* @param size - File size in bytes
|
||||
* @returns Hash string (8 hex characters)
|
||||
*/
|
||||
export function hashFileInfo(path: string, size: number): string {
|
||||
return hashString(`${path}:${size}`);
|
||||
}
|
||||
227
packages/engine/asset-system/src/utils/PathValidator.ts
Normal file
227
packages/engine/asset-system/src/utils/PathValidator.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Path Validator
|
||||
* 路径验证器
|
||||
*
|
||||
* Validates and sanitizes asset paths for security
|
||||
* 验证并清理资产路径以确保安全
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path validation options.
|
||||
* 路径验证选项。
|
||||
*/
|
||||
export interface PathValidationOptions {
|
||||
/** Allow absolute paths (for editor environment). | 允许绝对路径(用于编辑器环境)。 */
|
||||
allowAbsolutePaths?: boolean;
|
||||
/** Allow URLs (http://, https://, asset://). | 允许 URL。 */
|
||||
allowUrls?: boolean;
|
||||
}
|
||||
|
||||
export class PathValidator {
|
||||
// Dangerous path patterns (without absolute path checks)
|
||||
private static readonly DANGEROUS_PATTERNS_STRICT = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/^[/\\]/, // Absolute paths on Unix
|
||||
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Dangerous path patterns (allowing absolute paths)
|
||||
private static readonly DANGEROUS_PATTERNS_RELAXED = [
|
||||
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||||
/\0/, // Null bytes
|
||||
/%00/, // URL encoded null bytes
|
||||
/\.\.%2[fF]/ // URL encoded path traversal
|
||||
];
|
||||
|
||||
// Valid path characters for relative paths (alphanumeric, dash, underscore, dot, slash)
|
||||
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
|
||||
|
||||
// Valid path characters for absolute paths (includes colon for Windows drives)
|
||||
private static readonly VALID_ABSOLUTE_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@:]+$/;
|
||||
|
||||
// URL pattern
|
||||
private static readonly URL_REGEX = /^(https?|asset|blob|data):\/\//;
|
||||
|
||||
// Maximum path length
|
||||
private static readonly MAX_PATH_LENGTH = 1024;
|
||||
|
||||
/** Global options for path validation. | 路径验证的全局选项。 */
|
||||
private static _globalOptions: PathValidationOptions = {
|
||||
allowAbsolutePaths: false,
|
||||
allowUrls: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Set global validation options.
|
||||
* 设置全局验证选项。
|
||||
*/
|
||||
static setGlobalOptions(options: PathValidationOptions): void {
|
||||
this._globalOptions = { ...this._globalOptions, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current global options.
|
||||
* 获取当前全局选项。
|
||||
*/
|
||||
static getGlobalOptions(): PathValidationOptions {
|
||||
return { ...this._globalOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a path is safe
|
||||
* 验证路径是否安全
|
||||
*/
|
||||
static validate(path: string, options?: PathValidationOptions): { valid: boolean; reason?: string } {
|
||||
const opts = { ...this._globalOptions, ...options };
|
||||
|
||||
// Check for null/undefined/empty
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, reason: 'Path is empty or invalid' };
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (path.length > this.MAX_PATH_LENGTH) {
|
||||
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
|
||||
}
|
||||
|
||||
// Allow URLs if enabled
|
||||
if (opts.allowUrls && this.URL_REGEX.test(path)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Choose patterns based on options
|
||||
const patterns = opts.allowAbsolutePaths
|
||||
? this.DANGEROUS_PATTERNS_RELAXED
|
||||
: this.DANGEROUS_PATTERNS_STRICT;
|
||||
|
||||
// Check for dangerous patterns
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(path)) {
|
||||
return { valid: false, reason: 'Path contains dangerous pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid characters
|
||||
const validCharsRegex = opts.allowAbsolutePaths
|
||||
? this.VALID_ABSOLUTE_PATH_REGEX
|
||||
: this.VALID_PATH_REGEX;
|
||||
|
||||
if (!validCharsRegex.test(path)) {
|
||||
return { valid: false, reason: 'Path contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path
|
||||
* 清理路径
|
||||
*/
|
||||
static sanitize(path: string): string {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove dangerous patterns
|
||||
let sanitized = path;
|
||||
|
||||
// Remove path traversal (apply repeatedly until fully removed)
|
||||
let prev;
|
||||
do {
|
||||
prev = sanitized;
|
||||
sanitized = sanitized.replace(/\.\.[/\\]/g, '');
|
||||
} while (sanitized !== prev);
|
||||
|
||||
// Remove leading slashes
|
||||
sanitized = sanitized.replace(/^[/\\]+/, '');
|
||||
|
||||
// Remove null bytes
|
||||
sanitized = sanitized.replace(/\0/g, '');
|
||||
sanitized = sanitized.replace(/%00/g, '');
|
||||
|
||||
// Remove invalid Windows characters
|
||||
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
|
||||
|
||||
// Normalize slashes
|
||||
sanitized = sanitized.replace(/\\/g, '/');
|
||||
|
||||
// Remove double slashes
|
||||
sanitized = sanitized.replace(/\/+/g, '/');
|
||||
|
||||
// Trim whitespace
|
||||
sanitized = sanitized.trim();
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.length > this.MAX_PATH_LENGTH) {
|
||||
sanitized = sanitized.substring(0, this.MAX_PATH_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is trying to escape the base directory
|
||||
* 检查路径是否试图逃离基础目录
|
||||
*/
|
||||
static isPathTraversal(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
return normalized.includes('../') || normalized.includes('..\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path for consistent handling
|
||||
* 规范化路径以便一致处理
|
||||
*/
|
||||
static normalize(path: string): string {
|
||||
if (!path) return '';
|
||||
|
||||
// Sanitize first
|
||||
let normalized = this.sanitize(path);
|
||||
|
||||
// Convert backslashes to forward slashes
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash (except for root)
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join path segments safely
|
||||
* 安全地连接路径段
|
||||
*/
|
||||
static join(...segments: string[]): string {
|
||||
const validSegments = segments
|
||||
.filter((s) => s && typeof s === 'string')
|
||||
.map((s) => this.sanitize(s))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (validSegments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.normalize(validSegments.join('/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension safely
|
||||
* 安全地获取文件扩展名
|
||||
*/
|
||||
static getExtension(path: string): string {
|
||||
const sanitized = this.sanitize(path);
|
||||
const lastDot = sanitized.lastIndexOf('.');
|
||||
const lastSlash = sanitized.lastIndexOf('/');
|
||||
|
||||
if (lastDot > lastSlash && lastDot > 0) {
|
||||
return sanitized.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
81
packages/engine/asset-system/src/utils/UVHelper.ts
Normal file
81
packages/engine/asset-system/src/utils/UVHelper.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* UV Coordinate Helper
|
||||
* UV 坐标辅助工具
|
||||
*
|
||||
* 引擎使用图像坐标系:
|
||||
* Engine uses image coordinate system:
|
||||
* - 原点 (0, 0) 在左上角 | Origin at top-left
|
||||
* - V 轴向下增长 | V-axis increases downward
|
||||
* - UV 格式:[u0, v0, u1, v1] 其中 v0 < v1
|
||||
*/
|
||||
export class UVHelper {
|
||||
/**
|
||||
* Calculate UV coordinates for a texture region
|
||||
* 计算纹理区域的 UV 坐标
|
||||
*/
|
||||
static calculateUV(
|
||||
imageRect: { x: number; y: number; width: number; height: number },
|
||||
textureSize: { width: number; height: number }
|
||||
): [number, number, number, number] {
|
||||
const { x, y, width, height } = imageRect;
|
||||
const { width: tw, height: th } = textureSize;
|
||||
|
||||
return [
|
||||
x / tw, // u0
|
||||
y / th, // v0
|
||||
(x + width) / tw, // u1
|
||||
(y + height) / th // v1
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate UV coordinates for a tile in a tileset
|
||||
* 计算 tileset 中某个 tile 的 UV 坐标
|
||||
*/
|
||||
static calculateTileUV(
|
||||
tileIndex: number,
|
||||
tilesetInfo: {
|
||||
columns: number;
|
||||
tileWidth: number;
|
||||
tileHeight: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
margin?: number;
|
||||
spacing?: number;
|
||||
}
|
||||
): [number, number, number, number] | null {
|
||||
if (tileIndex < 0) return null;
|
||||
|
||||
const {
|
||||
columns,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
margin = 0,
|
||||
spacing = 0
|
||||
} = tilesetInfo;
|
||||
|
||||
const col = tileIndex % columns;
|
||||
const row = Math.floor(tileIndex / columns);
|
||||
const x = margin + col * (tileWidth + spacing);
|
||||
const y = margin + row * (tileHeight + spacing);
|
||||
|
||||
return this.calculateUV(
|
||||
{ x, y, width: tileWidth, height: tileHeight },
|
||||
{ width: imageWidth, height: imageHeight }
|
||||
);
|
||||
}
|
||||
|
||||
static validateUV(uv: [number, number, number, number]): boolean {
|
||||
const [u0, v0, u1, v1] = uv;
|
||||
return u0 >= 0 && u0 <= 1 && u1 >= 0 && u1 <= 1 &&
|
||||
v0 >= 0 && v0 <= 1 && v1 >= 0 && v1 <= 1 &&
|
||||
u0 < u1 && v0 < v1;
|
||||
}
|
||||
|
||||
static debugPrint(uv: [number, number, number, number], label?: string): void {
|
||||
const prefix = label ? `[${label}] ` : '';
|
||||
console.log(`${prefix}UV: [${uv.map(n => n.toFixed(4)).join(', ')}]`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user