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:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,52 @@
{
"id": "tilemap",
"name": "@esengine/tilemap",
"globalKey": "tilemap",
"displayName": "Tilemap 2D",
"description": "2D tilemap rendering and editing | 2D 瓦片地图渲染和编辑",
"version": "1.0.0",
"category": "Rendering",
"icon": "Grid3X3",
"tags": [
"2d",
"tilemap",
"tiled"
],
"isCore": false,
"defaultEnabled": false,
"isEngineModule": true,
"canContainContent": true,
"platforms": [
"web",
"desktop"
],
"dependencies": [
"core",
"math",
"sprite",
"asset-system"
],
"externalDependencies": [
"@esengine/physics-rapier2d"
],
"exports": {
"components": [
"TilemapComponent"
],
"systems": [
"TilemapRenderingSystem"
],
"loaders": [
"TilemapLoader",
"TilesetLoader"
]
},
"assetExtensions": {
".tilemap.json": "tilemap",
".tileset.json": "tileset"
},
"editorPackage": "@esengine/tilemap-editor",
"requiresWasm": false,
"outputPath": "dist/index.js",
"pluginExport": "TilemapPlugin"
}

View File

@@ -0,0 +1,60 @@
{
"name": "@esengine/tilemap",
"version": "1.0.0",
"description": "Tilemap system for ECS Framework - supports Tiled editor import",
"esengine": {
"plugin": true,
"pluginExport": "TilemapPlugin",
"category": "tilemap"
},
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"keywords": [
"ecs",
"tilemap",
"tiled",
"game-engine",
"2d",
"typescript"
],
"scripts": {
"clean": "rimraf dist tsconfig.tsbuildinfo",
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"rebuild": "npm run clean && npm run build"
},
"author": "yhh",
"license": "MIT",
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/physics-rapier2d": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.0",
"tsup": "^8.0.0",
"typescript": "^5.8.3"
},
"dependencies": {
"tslib": "^2.8.1"
},
"repository": {
"type": "git",
"url": "https://github.com/esengine/esengine.git",
"directory": "packages/tilemap"
}
}

View File

@@ -0,0 +1,41 @@
{
"id": "@esengine/tilemap",
"name": "Tilemap System",
"version": "1.0.0",
"description": "瓦片地图系统,支持 Tiled 格式导入和高效渲染 | Tilemap system with Tiled format import and efficient rendering",
"author": "ESEngine Team",
"license": "MIT",
"category": "rendering",
"tags": ["tilemap", "tiled", "2d", "rendering"],
"icon": "Grid3X3",
"enabledByDefault": true,
"canContainContent": true,
"isEnginePlugin": true,
"isCore": false,
"modules": [
{
"name": "TilemapRuntime",
"type": "runtime",
"loadingPhase": "default",
"entry": "./src/index.ts",
"components": ["TilemapComponent"],
"systems": ["TilemapRenderingSystem"]
},
{
"name": "TilemapEditor",
"type": "editor",
"loadingPhase": "default",
"entry": "./src/editor/index.ts",
"panels": ["tilemap-editor"],
"inspectors": ["TilemapInspectorProvider"],
"gizmoProviders": ["TilemapGizmoProvider"]
}
],
"dependencies": [
{
"id": "@esengine/core",
"version": "^1.0.0"
}
],
"platforms": ["web", "desktop"]
}

View File

@@ -0,0 +1,207 @@
/**
* Tilemap Animation System
* 瓦片地图动画系统
*
* Manages tile animation playback for all animated tiles in tilesets.
* 管理图块集中所有动画瓦片的动画播放。
*/
import type { ITilesetData, ITileMetadata } from './TilemapComponent';
/**
* Animation state for a single animated tile
* 单个动画瓦片的动画状态
*/
interface TileAnimationState {
/** Current frame index | 当前帧索引 */
currentFrame: number;
/** Elapsed time since last frame change (ms) | 自上次帧变化以来的时间(毫秒) */
elapsedTime: number;
}
/**
* Tilemap Animation System
* 瓦片地图动画系统
*/
export class TilemapAnimationSystem {
/** Animation states keyed by "tilesetIndex:tileId" | 按"图块集索引:瓦片ID"索引的动画状态 */
private animationStates: Map<string, TileAnimationState> = new Map();
/** Cached animated tile metadata for quick lookup | 缓存的动画瓦片元数据用于快速查找 */
private animatedTiles: Map<string, ITileMetadata> = new Map();
/** Whether animations are playing | 动画是否正在播放 */
private _isPlaying: boolean = true;
/**
* Register a tileset's animated tiles
* 注册图块集的动画瓦片
*/
registerTileset(tilesetIndex: number, tileset: ITilesetData): void {
if (!tileset.tiles) return;
for (const tile of tileset.tiles) {
if (tile.animation && tile.animation.frames.length > 0) {
const key = `${tilesetIndex}:${tile.id}`;
this.animatedTiles.set(key, tile);
this.animationStates.set(key, {
currentFrame: 0,
elapsedTime: 0
});
}
}
}
/**
* Unregister a tileset
* 注销图块集
*/
unregisterTileset(tilesetIndex: number): void {
const keysToRemove: string[] = [];
for (const key of this.animationStates.keys()) {
if (key.startsWith(`${tilesetIndex}:`)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
this.animationStates.delete(key);
this.animatedTiles.delete(key);
}
}
/**
* Clear all animation states
* 清除所有动画状态
*/
clear(): void {
this.animationStates.clear();
this.animatedTiles.clear();
}
/**
* Update all animations
* 更新所有动画
* @param deltaTime Time since last update in milliseconds | 自上次更新以来的时间(毫秒)
*/
update(deltaTime: number): void {
if (!this._isPlaying) return;
for (const [key, state] of this.animationStates) {
const tile = this.animatedTiles.get(key);
if (!tile?.animation) continue;
const frames = tile.animation.frames;
const currentFrame = frames[state.currentFrame];
if (!currentFrame) continue;
state.elapsedTime += deltaTime;
// Advance frames while elapsed time exceeds frame duration
while (state.elapsedTime >= currentFrame.duration) {
state.elapsedTime -= currentFrame.duration;
state.currentFrame = (state.currentFrame + 1) % frames.length;
}
}
}
/**
* Get the current display tile ID for an animated tile
* 获取动画瓦片的当前显示瓦片ID
* @param tilesetIndex Tileset index | 图块集索引
* @param tileId Original tile ID | 原始瓦片ID
* @returns Current frame's tile ID, or original if not animated | 当前帧的瓦片ID如果不是动画则返回原始ID
*/
getCurrentTileId(tilesetIndex: number, tileId: number): number {
const key = `${tilesetIndex}:${tileId}`;
const tile = this.animatedTiles.get(key);
const state = this.animationStates.get(key);
if (!tile?.animation || !state) {
return tileId;
}
const frame = tile.animation.frames[state.currentFrame];
return frame?.tileId ?? tileId;
}
/**
* Check if a tile has animation
* 检查瓦片是否有动画
*/
hasAnimation(tilesetIndex: number, tileId: number): boolean {
const key = `${tilesetIndex}:${tileId}`;
return this.animatedTiles.has(key);
}
/**
* Get animation metadata for a tile
* 获取瓦片的动画元数据
*/
getAnimation(tilesetIndex: number, tileId: number): ITileMetadata | undefined {
const key = `${tilesetIndex}:${tileId}`;
return this.animatedTiles.get(key);
}
/**
* Reset animation to first frame
* 重置动画到第一帧
*/
resetAnimation(tilesetIndex: number, tileId: number): void {
const key = `${tilesetIndex}:${tileId}`;
const state = this.animationStates.get(key);
if (state) {
state.currentFrame = 0;
state.elapsedTime = 0;
}
}
/**
* Reset all animations to first frame
* 重置所有动画到第一帧
*/
resetAll(): void {
for (const state of this.animationStates.values()) {
state.currentFrame = 0;
state.elapsedTime = 0;
}
}
/**
* Play/pause animations
* 播放/暂停动画
*/
get isPlaying(): boolean {
return this._isPlaying;
}
set isPlaying(value: boolean) {
this._isPlaying = value;
}
/**
* Toggle play/pause
* 切换播放/暂停
*/
togglePlayback(): boolean {
this._isPlaying = !this._isPlaying;
return this._isPlaying;
}
/**
* Get all animated tile IDs for a tileset
* 获取图块集的所有动画瓦片ID
*/
getAnimatedTileIds(tilesetIndex: number): number[] {
const ids: number[] = [];
for (const key of this.animatedTiles.keys()) {
if (key.startsWith(`${tilesetIndex}:`)) {
const tileId = parseInt(key.split(':')[1], 10);
ids.push(tileId);
}
}
return ids;
}
}
/** Global animation system instance | 全局动画系统实例 */
export const tilemapAnimationSystem = new TilemapAnimationSystem();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { AssetManagerToken } from '@esengine/asset-system';
import { RenderSystemToken } from '@esengine/ecs-engine-bindgen';
import { Physics2DWorldToken } from '@esengine/physics-rapier2d';
import { TilemapComponent } from './TilemapComponent';
import { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
import { TilemapCollider2DComponent } from './physics/TilemapCollider2DComponent';
import { TilemapPhysicsSystem } from './physics/TilemapPhysicsSystem';
import { TilemapLoader } from './loaders/TilemapLoader';
import { TilemapAssetType } from './constants';
import {
TilemapSystemToken,
TilemapPhysicsSystemToken
} from './tokens';
// 重新导出 tokens | Re-export tokens
export {
TilemapSystemToken,
TilemapPhysicsSystemToken
} from './tokens';
class TilemapRuntimeModule implements IRuntimeModule {
private _tilemapPhysicsSystem: TilemapPhysicsSystem | null = null;
private _loaderRegistered = false;
registerComponents(registry: IComponentRegistry): void {
registry.register(TilemapComponent);
registry.register(TilemapCollider2DComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
// 从服务注册表获取依赖 | Get dependencies from service registry
const assetManager = context.services.get(AssetManagerToken);
const renderSystem = context.services.get(RenderSystemToken);
if (!this._loaderRegistered && assetManager) {
assetManager.registerLoader(TilemapAssetType, new TilemapLoader());
this._loaderRegistered = true;
}
const tilemapSystem = new TilemapRenderingSystem();
scene.addSystem(tilemapSystem);
if (renderSystem) {
renderSystem.addRenderDataProvider(tilemapSystem);
}
this._tilemapPhysicsSystem = new TilemapPhysicsSystem();
scene.addSystem(this._tilemapPhysicsSystem);
// 注册服务到服务注册表 | Register services to service registry
context.services.register(TilemapSystemToken, tilemapSystem);
context.services.register(TilemapPhysicsSystemToken, this._tilemapPhysicsSystem);
}
onSystemsCreated(_scene: IScene, context: SystemContext): void {
// 从服务注册表获取物理世界 | Get physics world from service registry
const physics2DWorld = context.services.get(Physics2DWorldToken);
if (this._tilemapPhysicsSystem && physics2DWorld) {
this._tilemapPhysicsSystem.setPhysicsWorld(physics2DWorld);
}
}
get tilemapPhysicsSystem(): TilemapPhysicsSystem | null {
return this._tilemapPhysicsSystem;
}
}
const manifest: ModuleManifest = {
id: 'tilemap',
name: '@esengine/tilemap',
displayName: 'Tilemap 2D',
version: '1.0.0',
description: 'Tilemap system with Tiled editor support',
category: 'Rendering',
icon: 'Grid3X3',
isCore: false,
defaultEnabled: false,
isEngineModule: true,
canContainContent: true,
dependencies: ['core', 'math', 'sprite', 'asset-system'],
exports: { components: ['TilemapComponent', 'TilemapCollider2DComponent'] },
editorPackage: '@esengine/tilemap-editor'
};
export const TilemapPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new TilemapRuntimeModule()
};
export { TilemapRuntimeModule };

View File

@@ -0,0 +1,9 @@
/**
* Tilemap Constants
* 瓦片地图常量
*/
// Asset type constants for tilemap
// 瓦片地图资产类型常量
export const TilemapAssetType = 'tilemap' as const;
export const TilesetAssetType = 'tileset' as const;

View File

@@ -0,0 +1,44 @@
/**
* Tilemap System for ECS Framework
* ECS框架的瓦片地图系统
*/
// Constants
export { TilemapAssetType, TilesetAssetType } from './constants';
// Component
export { TilemapComponent } from './TilemapComponent';
export type { ITilemapData, ITilesetData, ITileMetadata, ITileAnimation, ITileAnimationFrame } from './TilemapComponent';
export type { ResizeAnchor } from './TilemapComponent';
// Animation System
export { TilemapAnimationSystem, tilemapAnimationSystem } from './TilemapAnimationSystem';
// Systems
export { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
export type { TilemapRenderData, ViewportBounds } from './systems/TilemapRenderingSystem';
// Physics
export { TilemapCollider2DComponent, TilemapColliderMode } from './physics/TilemapCollider2DComponent';
export type { CollisionRect } from './physics/TilemapCollider2DComponent';
export { TilemapPhysicsSystem } from './physics/TilemapPhysicsSystem';
export type { IPhysicsWorld, IPhysics2DSystem } from './physics/TilemapPhysicsSystem';
// Loaders
export { TilemapLoader } from './loaders/TilemapLoader';
export type { ITilemapAsset } from './loaders/TilemapLoader';
export { TilesetLoader } from './loaders/TilesetLoader';
export type { ITilesetAsset } from './loaders/TilesetLoader';
// Tiled converter
export { TiledConverter } from './loaders/TiledConverter';
export type { ITiledMap, ITiledConversionResult } from './loaders/TiledConverter';
// Runtime module and plugin
export { TilemapRuntimeModule, TilemapPlugin } from './TilemapRuntimeModule';
// Service tokens | 服务令牌
export {
TilemapSystemToken,
TilemapPhysicsSystemToken
} from './tokens';

View File

@@ -0,0 +1,332 @@
/**
* Tiled Map Editor format converter
* Tiled 地图编辑器格式转换器
*
* Converts Tiled JSON export format to our internal tilemap/tileset format.
* 将 Tiled JSON 导出格式转换为内部 tilemap/tileset 格式。
*/
import { ITilemapAsset } from './TilemapLoader';
import { ITilesetAsset } from './TilesetLoader';
/**
* Tiled map JSON format (exported from Tiled)
* Tiled 地图 JSON 格式
*/
export interface ITiledMap {
/** Map width in tiles */
width: number;
/** Map height in tiles */
height: number;
/** Tile width in pixels */
tilewidth: number;
/** Tile height in pixels */
tileheight: number;
/** Map orientation (orthogonal, isometric, etc.) */
orientation: string;
/** Render order */
renderorder: string;
/** Layers array */
layers: ITiledLayer[];
/** Tilesets array */
tilesets: ITiledTileset[];
/** Custom properties */
properties?: ITiledProperty[];
/** Tiled version */
tiledversion?: string;
/** Map version */
version?: string | number;
}
/**
* Tiled layer format
* Tiled 图层格式
*/
export interface ITiledLayer {
/** Layer name */
name: string;
/** Layer type (tilelayer, objectgroup, imagelayer, group) */
type: string;
/** Layer ID */
id: number;
/** Layer width in tiles */
width?: number;
/** Layer height in tiles */
height?: number;
/** Tile data (for tilelayer) */
data?: number[];
/** Layer visibility */
visible: boolean;
/** Layer opacity (0-1) */
opacity: number;
/** Layer X offset */
x: number;
/** Layer Y offset */
y: number;
/** Objects (for objectgroup) */
objects?: ITiledObject[];
/** Custom properties */
properties?: ITiledProperty[];
}
/**
* Tiled object format
* Tiled 对象格式
*/
export interface ITiledObject {
id: number;
name: string;
type: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
visible: boolean;
properties?: ITiledProperty[];
}
/**
* Tiled tileset format
* Tiled 瓦片集格式
*/
export interface ITiledTileset {
/** First GID (global tile ID) */
firstgid: number;
/** Tileset name */
name: string;
/** Image path */
image: string;
/** Image width */
imagewidth: number;
/** Image height */
imageheight: number;
/** Tile width */
tilewidth: number;
/** Tile height */
tileheight: number;
/** Tile count */
tilecount: number;
/** Columns */
columns: number;
/** Margin */
margin: number;
/** Spacing */
spacing: number;
/** Tile properties/metadata */
tiles?: ITiledTile[];
/** External tileset source (if embedded) */
source?: string;
}
/**
* Tiled tile metadata
* Tiled 瓦片元数据
*/
export interface ITiledTile {
id: number;
type?: string;
properties?: ITiledProperty[];
}
/**
* Tiled property format
* Tiled 属性格式
*/
export interface ITiledProperty {
name: string;
type: string;
value: unknown;
}
/**
* Conversion result
* 转换结果
*/
export interface ITiledConversionResult {
/** Converted tilemap */
tilemap: ITilemapAsset;
/** Converted tilesets */
tilesets: ITilesetAsset[];
/** Object layers (if any) */
objects: Array<{
name: string;
objects: ITiledObject[];
}>;
}
/**
* Tiled format converter
* Tiled 格式转换器
*/
export class TiledConverter {
/**
* Convert Tiled JSON map to internal format
* 将 Tiled JSON 地图转换为内部格式
*/
static convert(tiledMap: ITiledMap, mapName: string = 'tilemap'): ITiledConversionResult {
// Convert tilesets
const tilesets = tiledMap.tilesets.map(ts => this.convertTileset(ts));
// Find the first tile layer for main data
const tileLayer = tiledMap.layers.find(l => l.type === 'tilelayer');
// Convert tile data (Tiled uses global IDs, we need to adjust)
let data: number[] = [];
if (tileLayer && tileLayer.data) {
data = this.convertTileData(tileLayer.data, tiledMap.tilesets);
}
// Extract collision layer if exists
const collisionLayer = tiledMap.layers.find(
l => l.type === 'tilelayer' &&
(l.name.toLowerCase().includes('collision') || l.name.toLowerCase().includes('solid'))
);
const collisionData = collisionLayer?.data
? this.convertTileData(collisionLayer.data, tiledMap.tilesets)
: undefined;
// Collect all layers
const layers = tiledMap.layers
.filter(l => l.type === 'tilelayer')
.map(l => ({
name: l.name,
visible: l.visible,
opacity: l.opacity,
data: l.data ? this.convertTileData(l.data, tiledMap.tilesets) : undefined
}));
// Collect object layers
const objects = tiledMap.layers
.filter(l => l.type === 'objectgroup')
.map(l => ({
name: l.name,
objects: l.objects || []
}));
// Convert properties
const properties: Record<string, unknown> = {};
if (tiledMap.properties) {
for (const prop of tiledMap.properties) {
properties[prop.name] = prop.value;
}
}
const tilemap: ITilemapAsset = {
name: mapName,
version: 1,
width: tiledMap.width,
height: tiledMap.height,
tileWidth: tiledMap.tilewidth,
tileHeight: tiledMap.tileheight,
tileset: tilesets.length > 0 ? tilesets[0].name : '',
data,
layers: layers.length > 1 ? layers : undefined,
collisionData,
properties
};
return {
tilemap,
tilesets,
objects
};
}
/**
* Convert Tiled tileset to internal format
* 将 Tiled 瓦片集转换为内部格式
*/
private static convertTileset(tiledTileset: ITiledTileset): ITilesetAsset {
const tiles = tiledTileset.tiles?.map(t => ({
id: t.id,
type: t.type,
properties: t.properties?.reduce((acc, p) => {
acc[p.name] = p.value;
return acc;
}, {} as Record<string, unknown>)
}));
return {
name: tiledTileset.name,
version: 1,
image: tiledTileset.image,
imageWidth: tiledTileset.imagewidth,
imageHeight: tiledTileset.imageheight,
tileWidth: tiledTileset.tilewidth,
tileHeight: tiledTileset.tileheight,
tileCount: tiledTileset.tilecount,
columns: tiledTileset.columns,
rows: Math.ceil(tiledTileset.tilecount / tiledTileset.columns),
margin: tiledTileset.margin || 0,
spacing: tiledTileset.spacing || 0,
tiles
};
}
/**
* Convert Tiled tile data (global IDs to local IDs)
* 转换 Tiled 瓦片数据全局ID到本地ID
*
* Tiled uses global tile IDs where each tileset has a firstgid.
* We convert to local IDs starting from 1 (0 = empty).
*/
private static convertTileData(data: number[], tilesets: ITiledTileset[]): number[] {
if (tilesets.length === 0) return data;
// For single tileset, simple conversion
if (tilesets.length === 1) {
const firstgid = tilesets[0].firstgid;
return data.map(gid => {
if (gid === 0) return 0;
// Clear flip flags (high bits in Tiled)
const tileId = gid & 0x1FFFFFFF;
return tileId - firstgid + 1;
});
}
// For multiple tilesets, find which tileset each tile belongs to
return data.map(gid => {
if (gid === 0) return 0;
const tileId = gid & 0x1FFFFFFF;
// Find the tileset this tile belongs to
let tileset: ITiledTileset | null = null;
for (let i = tilesets.length - 1; i >= 0; i--) {
if (tileId >= tilesets[i].firstgid) {
tileset = tilesets[i];
break;
}
}
if (!tileset) return 0;
return tileId - tileset.firstgid + 1;
});
}
/**
* Parse Tiled JSON string
* 解析 Tiled JSON 字符串
*/
static parse(jsonString: string): ITiledMap {
return JSON.parse(jsonString) as ITiledMap;
}
/**
* Convert and stringify to internal format
* 转换并序列化为内部格式
*/
static convertToJson(tiledMap: ITiledMap, mapName?: string): {
tilemapJson: string;
tilesetJsons: string[];
} {
const result = this.convert(tiledMap, mapName);
return {
tilemapJson: JSON.stringify(result.tilemap, null, 2),
tilesetJsons: result.tilesets.map(ts => JSON.stringify(ts, null, 2))
};
}
}

View File

@@ -0,0 +1,93 @@
/**
* Tilemap asset loader
* 瓦片地图资产加载器
*/
import {
IAssetLoader,
IAssetContent,
IAssetParseContext,
AssetContentType
} from '@esengine/asset-system';
import { TilemapAssetType } from '../constants';
/**
* Tilemap data interface
* 瓦片地图数据接口
*/
export interface ITilemapAsset {
/** 名称 */
name: string;
/** 版本 */
version: number;
/** 宽度(瓦片数) */
width: number;
/** 高度(瓦片数) */
height: number;
/** 瓦片宽度(像素) */
tileWidth: number;
/** 瓦片高度(像素) */
tileHeight: number;
/** 瓦片集资源GUID */
tileset: string;
/** 瓦片数据行主序0表示空 */
data: number[];
/** 图层(可选) */
layers?: Array<{
name: string;
visible: boolean;
opacity: number;
data?: number[];
/** 材质路径 */
materialPath?: string;
}>;
/** 碰撞数据(可选) */
collisionData?: number[];
/** 自定义属性 */
properties?: Record<string, unknown>;
}
/**
* Tilemap loader implementation
* 瓦片地图加载器实现
*/
export class TilemapLoader implements IAssetLoader<ITilemapAsset> {
readonly supportedType = TilemapAssetType;
readonly supportedExtensions = ['.tilemap.json', '.tilemap'];
readonly contentType: AssetContentType = 'text';
/**
* Parse tilemap asset from text content
* 从文本内容解析瓦片地图资产
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITilemapAsset> {
if (!content.text) {
throw new Error('Tilemap content is empty');
}
const jsonData = JSON.parse(content.text) as ITilemapAsset;
// 验证必要字段
// Validate required fields
if (!jsonData.width || !jsonData.height || !jsonData.data) {
throw new Error('Invalid tilemap format: missing required fields');
}
return jsonData;
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITilemapAsset): void {
// 清理瓦片数据 | Clean up tile data
asset.data.length = 0;
if (asset.layers) {
asset.layers.length = 0;
}
if (asset.collisionData) {
asset.collisionData.length = 0;
}
}
}

View File

@@ -0,0 +1,102 @@
/**
* Tileset asset loader
* 瓦片集资产加载器
*/
import {
IAssetLoader,
IAssetContent,
IAssetParseContext,
AssetContentType
} from '@esengine/asset-system';
import { TilesetAssetType } from '../constants';
/**
* Tileset data interface
* 瓦片集数据接口
*/
export interface ITilesetAsset {
/** 名称 */
name: string;
/** 版本 */
version: number;
/** 纹理图像资源GUID或路径 */
image: string;
/** 图像宽度(像素) */
imageWidth: number;
/** 图像高度(像素) */
imageHeight: number;
/** 瓦片宽度(像素) */
tileWidth: number;
/** 瓦片高度(像素) */
tileHeight: number;
/** 瓦片总数 */
tileCount: number;
/** 列数 */
columns: number;
/** 行数 */
rows: number;
/** 边距(像素) */
margin?: number;
/** 间距(像素) */
spacing?: number;
/** 每个瓦片的元数据 */
tiles?: Array<{
id: number;
type?: string;
properties?: Record<string, unknown>;
}>;
}
/**
* Tileset loader implementation
* 瓦片集加载器实现
*/
export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
readonly supportedType = TilesetAssetType;
readonly supportedExtensions = ['.tileset.json', '.tileset'];
readonly contentType: AssetContentType = 'text';
/**
* Parse tileset asset from text content
* 从文本内容解析瓦片集资产
*/
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITilesetAsset> {
if (!content.text) {
throw new Error('Tileset content is empty');
}
const jsonData = JSON.parse(content.text) as ITilesetAsset;
// 验证必要字段
// Validate required fields
if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) {
throw new Error('Invalid tileset format: missing required fields');
}
// 计算派生字段(如果未提供)
// Calculate derived fields if not provided
if (!jsonData.columns && jsonData.imageWidth) {
jsonData.columns = Math.floor(jsonData.imageWidth / jsonData.tileWidth);
}
if (!jsonData.rows && jsonData.imageHeight) {
jsonData.rows = Math.floor(jsonData.imageHeight / jsonData.tileHeight);
}
if (!jsonData.tileCount && jsonData.columns && jsonData.rows) {
jsonData.tileCount = jsonData.columns * jsonData.rows;
}
return jsonData;
}
/**
* Dispose loaded asset
* 释放已加载的资产
*/
dispose(asset: ITilesetAsset): void {
// 清理瓦片元数据 | Clean up tile metadata
if (asset.tiles) {
asset.tiles.length = 0;
}
}
}

View File

@@ -0,0 +1,280 @@
/**
* TilemapCollider2D Component
* Tilemap 碰撞体组件
*
* 将 TilemapComponent 的碰撞数据转换为物理碰撞体。
* 使用优化算法合并相邻碰撞格子,减少碰撞体数量。
*/
import { Component, Property, Serialize, ECSComponent, Serializable } from '@esengine/ecs-framework';
/**
* 碰撞体生成模式
*/
export enum TilemapColliderMode {
/** 每个碰撞格子单独创建碰撞体 */
PerTile = 0,
/** 合并相邻碰撞格子为更大的矩形 */
Merged = 1,
/** 只生成边缘碰撞体(优化性能) */
EdgeOnly = 2,
}
/**
* 合并后的碰撞矩形
*/
export interface CollisionRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* TilemapCollider2D 组件
*
* 自动从同一实体的 TilemapComponent 读取碰撞数据,
* 并生成对应的物理碰撞体。
*/
@ECSComponent('TilemapCollider2D')
@Serializable({ version: 1, typeId: 'TilemapCollider2D' })
export class TilemapCollider2DComponent extends Component {
/**
* 碰撞体生成模式
*/
@Serialize()
@Property({
type: 'enum',
label: 'Collider Mode',
options: [
{ label: 'Per Tile', value: TilemapColliderMode.PerTile },
{ label: 'Merged', value: TilemapColliderMode.Merged },
{ label: 'Edge Only', value: TilemapColliderMode.EdgeOnly },
],
})
public colliderMode: TilemapColliderMode = TilemapColliderMode.Merged;
/**
* 碰撞层(该碰撞体所在的层)
*/
@Serialize()
@Property({ type: 'collisionLayer', label: 'Collision Layer' })
public collisionLayer: number = 1; // Default layer
/**
* 碰撞掩码(该碰撞体可以与哪些层碰撞)
*/
@Serialize()
@Property({ type: 'collisionMask', label: 'Collision Mask' })
public collisionMask: number = 0xFFFF; // All layers
/**
* 摩擦系数
*/
@Serialize()
@Property({ type: 'number', label: 'Friction', min: 0, max: 1, step: 0.01 })
public friction: number = 0.5;
/**
* 弹性系数
*/
@Serialize()
@Property({ type: 'number', label: 'Restitution', min: 0, max: 1, step: 0.01 })
public restitution: number = 0;
/**
* 是否为触发器
*/
@Serialize()
@Property({ type: 'boolean', label: 'Is Trigger' })
public isTrigger: boolean = false;
/**
* 生成的碰撞矩形缓存
* @internal
*/
public _collisionRects: CollisionRect[] = [];
/**
* 碰撞体句柄列表
* @internal
*/
public _colliderHandles: number[] = [];
/**
* 是否需要重建碰撞体
* @internal
*/
public _needsRebuild: boolean = true;
/**
* 碰撞数据版本(用于检测变化)
* @internal
*/
public _lastCollisionVersion: number = -1;
/**
* 从碰撞数据生成碰撞矩形
* @param collisionData 碰撞数据数组
* @param width 地图宽度(格子数)
* @param height 地图高度(格子数)
* @param tileWidth 格子宽度(像素)
* @param tileHeight 格子高度(像素)
*/
public generateCollisionRects(
collisionData: Uint32Array,
width: number,
height: number,
tileWidth: number,
tileHeight: number
): CollisionRect[] {
if (collisionData.length === 0) {
this._collisionRects = [];
return [];
}
switch (this.colliderMode) {
case TilemapColliderMode.PerTile:
this._collisionRects = this._generatePerTileRects(
collisionData, width, height, tileWidth, tileHeight
);
break;
case TilemapColliderMode.Merged:
this._collisionRects = this._generateMergedRects(
collisionData, width, height, tileWidth, tileHeight
);
break;
case TilemapColliderMode.EdgeOnly:
// Edge-only 模式暂时使用合并模式
this._collisionRects = this._generateMergedRects(
collisionData, width, height, tileWidth, tileHeight
);
break;
}
this._needsRebuild = true;
return this._collisionRects;
}
/**
* 每个格子单独生成矩形
*/
private _generatePerTileRects(
collisionData: Uint32Array,
width: number,
height: number,
tileWidth: number,
tileHeight: number
): CollisionRect[] {
const rects: CollisionRect[] = [];
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
if (collisionData[row * width + col] > 0) {
rects.push({
x: col * tileWidth,
y: row * tileHeight,
width: tileWidth,
height: tileHeight,
});
}
}
}
return rects;
}
/**
* 合并相邻格子生成更大的矩形(贪心算法)
*
* 使用行优先扫描,合并水平相邻的碰撞格子,
* 然后尝试垂直合并相同宽度的矩形。
*/
private _generateMergedRects(
collisionData: Uint32Array,
width: number,
height: number,
tileWidth: number,
tileHeight: number
): CollisionRect[] {
// 创建已处理标记数组
const processed = new Array(width * height).fill(false);
const rects: CollisionRect[] = [];
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const index = row * width + col;
// 跳过已处理或无碰撞的格子
if (processed[index] || collisionData[index] === 0) {
continue;
}
// 找到水平方向最大范围
let endCol = col;
while (
endCol < width &&
collisionData[row * width + endCol] > 0 &&
!processed[row * width + endCol]
) {
endCol++;
}
const rectWidth = endCol - col;
// 找到垂直方向最大范围(保持相同宽度)
let endRow = row;
let canExtend = true;
while (canExtend && endRow < height) {
// 检查这一行是否都有碰撞且未处理
for (let c = col; c < endCol; c++) {
const idx = endRow * width + c;
if (collisionData[idx] === 0 || processed[idx]) {
canExtend = false;
break;
}
}
if (canExtend) {
endRow++;
}
}
const rectHeight = endRow - row;
// 标记所有包含的格子为已处理
for (let r = row; r < endRow; r++) {
for (let c = col; c < endCol; c++) {
processed[r * width + c] = true;
}
}
// 添加合并后的矩形
rects.push({
x: col * tileWidth,
y: row * tileHeight,
width: rectWidth * tileWidth,
height: rectHeight * tileHeight,
});
}
}
return rects;
}
/**
* 获取碰撞矩形数量
*/
public getCollisionRectCount(): number {
return this._collisionRects.length;
}
/**
* 标记需要重建
*/
public markNeedsRebuild(): void {
this._needsRebuild = true;
}
public override onRemovedFromEntity(): void {
this._collisionRects = [];
this._colliderHandles = [];
}
}

View File

@@ -0,0 +1,173 @@
/**
* TilemapPhysicsSystem
* Tilemap 物理系统
*
* 负责将 TilemapComponent 的碰撞数据同步到物理世界。
* 需要与 Physics2DSystem 配合使用。
*/
import { EntitySystem, Matcher, ECSSystem, type Entity, type Scene } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import { TilemapComponent } from '../TilemapComponent';
import { TilemapCollider2DComponent, type CollisionRect } from './TilemapCollider2DComponent';
import type { IPhysics2DWorld } from '@esengine/physics-rapier2d';
// 重新导出类型以保持向后兼容 | Re-export types for backward compatibility
export type IPhysicsWorld = IPhysics2DWorld;
/**
* 物理系统接口
*/
export interface IPhysics2DSystem {
world: IPhysicsWorld;
}
/**
* Tilemap 物理系统
*
* 监听带有 TilemapComponent 和 TilemapCollider2DComponent 的实体,
* 自动将碰撞数据转换为物理碰撞体。
*/
@ECSSystem('TilemapPhysics', { updateOrder: 50 })
export class TilemapPhysicsSystem extends EntitySystem {
private _physicsWorld: IPhysicsWorld | null = null;
private _pendingEntities: Entity[] = [];
constructor() {
super(Matcher.empty().all(TilemapComponent, TilemapCollider2DComponent));
}
/**
* 设置物理世界引用
*/
public setPhysicsWorld(world: IPhysicsWorld): void {
this._physicsWorld = world;
// 处理待处理的实体
for (const entity of this._pendingEntities) {
this._createColliders(entity);
}
this._pendingEntities = [];
}
protected override onAdded(entity: Entity): void {
if (!this._physicsWorld) {
this._pendingEntities.push(entity);
return;
}
this._createColliders(entity);
}
protected override onRemoved(entity: Entity): void {
this._removeColliders(entity);
const idx = this._pendingEntities.indexOf(entity);
if (idx >= 0) {
this._pendingEntities.splice(idx, 1);
}
}
protected override process(entities: readonly Entity[]): void {
if (!this._physicsWorld) return;
for (const entity of entities) {
const tilemap = entity.getComponent(TilemapComponent);
const collider = entity.getComponent(TilemapCollider2DComponent);
if (!tilemap || !collider) continue;
// 检查碰撞数据是否变化
const currentVersion = tilemap.renderDirty ? Date.now() : collider._lastCollisionVersion;
if (collider._needsRebuild || currentVersion !== collider._lastCollisionVersion) {
this._rebuildColliders(entity);
collider._lastCollisionVersion = currentVersion;
}
}
}
/**
* 创建碰撞体
*/
private _createColliders(entity: Entity): void {
const tilemap = entity.getComponent(TilemapComponent);
const collider = entity.getComponent(TilemapCollider2DComponent);
const transform = entity.getComponent(TransformComponent);
if (!tilemap || !collider || !this._physicsWorld) return;
// 生成碰撞矩形
const collisionData = tilemap.collisionData;
collider.generateCollisionRects(
collisionData,
tilemap.width,
tilemap.height,
tilemap.tileWidth,
tilemap.tileHeight
);
// 获取实体位置偏移
const offsetX = transform?.position.x ?? 0;
const offsetY = transform?.position.y ?? 0;
// 计算地图总高度(像素),用于 Y 轴翻转
// Calculate total map height (pixels) for Y-axis flip
const mapPixelHeight = tilemap.height * tilemap.tileHeight;
// 为每个碰撞矩形创建物理碰撞体
for (const rect of collider._collisionRects) {
// Y 轴翻转rect.y 是从顶部计算的,需要翻转到底部
// Y-axis flip: rect.y is calculated from top, needs flip to bottom
const flippedY = mapPixelHeight - rect.y - rect.height;
const handle = this._physicsWorld.createStaticCollider(
entity.id,
{
x: offsetX + rect.x + rect.width / 2,
y: offsetY + flippedY + rect.height / 2,
},
{
x: rect.width / 2,
y: rect.height / 2,
},
collider.collisionLayer,
collider.collisionMask,
collider.friction,
collider.restitution,
collider.isTrigger
);
if (handle !== null) {
collider._colliderHandles.push(handle);
}
}
collider._needsRebuild = false;
this.logger.debug(`Created ${collider._colliderHandles.length} colliders for tilemap entity ${entity.name}`);
}
/**
* 移除碰撞体
*/
private _removeColliders(entity: Entity): void {
const collider = entity.getComponent(TilemapCollider2DComponent);
if (!collider || !this._physicsWorld) return;
for (const handle of collider._colliderHandles) {
this._physicsWorld.removeCollider(handle);
}
collider._colliderHandles = [];
}
/**
* 重建碰撞体
*/
private _rebuildColliders(entity: Entity): void {
this._removeColliders(entity);
this._createColliders(entity);
}
protected override onDestroy(): void {
this._physicsWorld = null;
this._pendingEntities = [];
}
}

View File

@@ -0,0 +1,435 @@
import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework';
import { SortingLayers, TransformComponent } from '@esengine/engine-core';
import { Color } from '@esengine/ecs-framework-math';
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent';
/** 度转弧度常量 | Degrees to radians constant */
const DEG_TO_RAD = Math.PI / 180;
/**
* Tilemap render data for a single tilemap layer
* 单个瓦片地图图层的渲染数据
*/
export interface TilemapRenderData {
/** Entity ID | 实体ID */
entityId: number;
/** Layer index within the tilemap | 图层在瓦片地图中的索引 */
layerIndex: number;
/** Transform data [x, y, rotation, scaleX, scaleY, originX, originY] per tile | 每个瓦片的变换数据 */
transforms: Float32Array;
/** Texture IDs per tile | 每个瓦片的纹理ID */
textureIds: Uint32Array;
/** UV coordinates [u0, v0, u1, v1] per tile | 每个瓦片的UV坐标 */
uvs: Float32Array;
/** Packed colors (ARGB) per tile | 每个瓦片的打包颜色 */
colors: Uint32Array;
/** Number of tiles in this layer | 此图层的瓦片数量 */
tileCount: number;
/**
* 排序层名称
* Sorting layer name
*/
sortingLayer: string;
/**
* 层内排序顺序
* Order within the sorting layer
*/
orderInLayer: number;
/** Sorting order for rendering (deprecated, use sortingLayer + orderInLayer) | 渲染排序顺序(已弃用,使用 sortingLayer + orderInLayer */
sortingOrder: number;
/** Texture path for loading | 纹理路径 */
texturePath?: string;
/** Material ID for this layer (0 = default) | 此图层的材质ID0 = 默认) */
materialId: number;
}
/**
* 视口边界
*/
export interface ViewportBounds {
left: number;
right: number;
top: number;
bottom: number;
}
/**
* Cache key for layer render data
* 图层渲染数据的缓存键
*/
type LayerCacheKey = `${number}_${number}`;
/**
* 瓦片地图渲染系统 - 准备瓦片地图渲染数据(按图层)
* Tilemap rendering system - Prepares tilemap render data (per layer)
*/
@ECSSystem('TilemapRendering', { updateOrder: 40 })
export class TilemapRenderingSystem extends EntitySystem implements IRenderDataProvider {
/** Cache for layer render data: key = "entityId_layerIndex" | 图层渲染数据缓存 */
private _layerRenderDataCache: Map<LayerCacheKey, TilemapRenderData> = new Map();
/** Current frame render data | 当前帧渲染数据 */
private _currentFrameData: TilemapRenderData[] = [];
/** Viewport bounds for culling | 视口边界用于剔除 */
private _viewportBounds: ViewportBounds | null = null;
constructor() {
super(Matcher.empty().all(TilemapComponent, TransformComponent));
}
/**
* Set viewport bounds for tile culling
* 设置视口边界用于瓦片剔除
*/
setViewportBounds(bounds: ViewportBounds): void {
this._viewportBounds = bounds;
}
/**
* Get render data for current frame
* 获取当前帧的渲染数据
*/
getRenderData(): readonly TilemapRenderData[] {
return this._currentFrameData;
}
protected override onBegin(): void {
this._currentFrameData = [];
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
if (!entity.enabled) continue;
const tilemap = entity.getComponent(TilemapComponent) as TilemapComponent | null;
const transform = entity.getComponent(TransformComponent) as TransformComponent | null;
if (!tilemap || !transform || !tilemap.visible) continue;
// Process each layer separately
// 分别处理每个图层
const layers = tilemap.layers;
for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
const layer = layers[layerIndex];
if (!layer.visible) continue;
const cacheKey: LayerCacheKey = `${entity.id}_${layerIndex}`;
let renderData = this._layerRenderDataCache.get(cacheKey);
if (!renderData || tilemap.renderDirty) {
renderData = this.buildLayerRenderData(entity.id, layerIndex, tilemap, transform, layer);
this._layerRenderDataCache.set(cacheKey, renderData);
} else {
this.updateLayerTransforms(renderData, layerIndex, tilemap, transform, layer);
}
if (renderData.tileCount > 0) {
this._currentFrameData.push(renderData);
}
}
// Clear dirty flag after processing all layers
// 处理完所有图层后清除脏标志
tilemap.renderDirty = false;
}
// Sort by sorting order (lower values render first)
// 按排序顺序排序(较小值先渲染)
this._currentFrameData.sort((a, b) => a.sortingOrder - b.sortingOrder);
}
/**
* Build render data for a single layer
* 为单个图层构建渲染数据
*/
private buildLayerRenderData(
entityId: number,
layerIndex: number,
tilemap: TilemapComponent,
transform: TransformComponent,
layer: ITilemapLayerData
): TilemapRenderData {
const layerData = tilemap.getLayerData(layerIndex);
if (!layerData) {
return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingLayer, tilemap.orderInLayer, tilemap.sortingOrder, layer.materialId);
}
const width = tilemap.width;
const height = tilemap.height;
const tileWidth = tilemap.tileWidth;
const tileHeight = tilemap.tileHeight;
// Calculate visible tile range
// 计算可见瓦片范围
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
width, height, tileWidth, tileHeight, transform
);
// Count non-empty tiles in this layer
// 计算此图层的非空瓦片数量
let tileCount = 0;
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
if (layerData[row * width + col] > 0) tileCount++;
}
}
if (tileCount === 0) {
return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingLayer, tilemap.orderInLayer, tilemap.sortingOrder, layer.materialId);
}
const transforms = new Float32Array(tileCount * 7);
const textureIds = new Uint32Array(tileCount);
const uvs = new Float32Array(tileCount * 4);
const colors = new Uint32Array(tileCount);
// Calculate color with layer opacity
// 计算带有图层透明度的颜色
const effectiveAlpha = tilemap.alpha * layer.opacity;
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
// Calculate rotation parameters
// 计算旋转参数(度转弧度)
const rotationRad = transform.rotation.z * DEG_TO_RAD;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
// Tilemap rotation pivot
// Tilemap 旋转中心点
const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2;
const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2;
let idx = 0;
let texturePath: string | undefined;
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
const gid = layerData[row * width + col];
if (gid <= 0) continue;
// Find corresponding tileset
// 查找对应的 tileset
const tilesetInfo = tilemap.getTilesetForGid(gid);
if (!tilesetInfo) continue;
const { index: tilesetIndex, localId } = tilesetInfo;
// Get texture path
// 获取纹理路径
if (!texturePath && tilemap.tilesets[tilesetIndex]) {
texturePath = tilemap.tilesets[tilesetIndex].source;
}
// Calculate tile local position (relative to tilemap center)
// 计算瓦片的本地位置(相对于 tilemap 中心)
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
// Apply rotation transform (clockwise positive)
// 应用旋转变换(顺时针为正)
const rotatedX = localX * cos + localY * sin + pivotX;
const rotatedY = -localX * sin + localY * cos + pivotY;
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
const tOffset = idx * 7;
transforms[tOffset] = rotatedX;
transforms[tOffset + 1] = rotatedY;
transforms[tOffset + 2] = transform.rotation.z;
transforms[tOffset + 3] = tileWidth * transform.scale.x;
transforms[tOffset + 4] = tileHeight * transform.scale.y;
transforms[tOffset + 5] = 0.5;
transforms[tOffset + 6] = 0.5;
// Texture ID
textureIds[idx] = tilemap.tilesets[tilesetIndex]?.textureId || 0;
// UV coordinates
const tileUV = tilemap.getTileUV(tilesetIndex, localId);
const uvOffset = idx * 4;
if (tileUV) {
uvs[uvOffset] = tileUV[0];
uvs[uvOffset + 1] = tileUV[1];
uvs[uvOffset + 2] = tileUV[2];
uvs[uvOffset + 3] = tileUV[3];
} else {
uvs[uvOffset] = 0;
uvs[uvOffset + 1] = 0;
uvs[uvOffset + 2] = 1;
uvs[uvOffset + 3] = 1;
}
colors[idx] = colorValue;
idx++;
}
}
return {
entityId,
layerIndex,
transforms,
textureIds,
uvs,
colors,
tileCount,
sortingLayer: tilemap.sortingLayer,
orderInLayer: tilemap.orderInLayer + layerIndex,
sortingOrder: tilemap.sortingOrder + layerIndex * 0.001,
texturePath,
materialId: layer.materialId ?? 0
};
}
/**
* Update transforms for a layer (when only position/rotation/scale changed)
* 更新图层的变换(当只有位置/旋转/缩放改变时)
*/
private updateLayerTransforms(
renderData: TilemapRenderData,
layerIndex: number,
tilemap: TilemapComponent,
transform: TransformComponent,
layer: ITilemapLayerData
): void {
const layerData = tilemap.getLayerData(layerIndex);
if (!layerData) return;
const width = tilemap.width;
const height = tilemap.height;
const tileWidth = tilemap.tileWidth;
const tileHeight = tilemap.tileHeight;
// Calculate visible tile range
// 计算可见瓦片范围
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
width, height, tileWidth, tileHeight, transform
);
// Calculate rotation parameters
// 计算旋转参数(度转弧度)
const rotationRad = transform.rotation.z * DEG_TO_RAD;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
// Tilemap rotation pivot
// Tilemap 旋转中心点
const pivotX = transform.position.x + (width * tileWidth * transform.scale.x) / 2;
const pivotY = transform.position.y + (height * tileHeight * transform.scale.y) / 2;
let idx = 0;
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
if (layerData[row * width + col] <= 0) continue;
// Calculate tile local position
// 计算瓦片本地位置
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
// Apply rotation transform (clockwise positive)
// 应用旋转变换(顺时针为正)
const rotatedX = localX * cos + localY * sin + pivotX;
const rotatedY = -localX * sin + localY * cos + pivotY;
const tOffset = idx * 7;
renderData.transforms[tOffset] = rotatedX;
renderData.transforms[tOffset + 1] = rotatedY;
renderData.transforms[tOffset + 2] = transform.rotation.z;
renderData.transforms[tOffset + 3] = tileWidth * transform.scale.x;
renderData.transforms[tOffset + 4] = tileHeight * transform.scale.y;
idx++;
}
}
// Update color (alpha or layer opacity may have changed)
// 更新颜色alpha 或图层透明度可能已更改)
const effectiveAlpha = tilemap.alpha * layer.opacity;
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
for (let i = 0; i < renderData.colors.length; i++) {
renderData.colors[i] = colorValue;
}
// Update sorting order and material ID
// 更新排序顺序和材质ID
renderData.sortingOrder = tilemap.sortingOrder + layerIndex * 0.001;
renderData.materialId = layer.materialId ?? 0;
}
/**
* Calculate visible tile range based on viewport bounds
* 根据视口边界计算可见瓦片范围
*/
private calculateVisibleRange(
width: number,
height: number,
tileWidth: number,
tileHeight: number,
transform: TransformComponent
): { startCol: number; endCol: number; startRow: number; endRow: number } {
let startCol = 0, endCol = width;
let startRow = 0, endRow = height;
if (this._viewportBounds) {
const bounds = this._viewportBounds;
const mapX = transform.position.x;
const mapY = transform.position.y;
startCol = Math.max(0, Math.floor((bounds.left - mapX) / tileWidth));
endCol = Math.min(width, Math.ceil((bounds.right - mapX) / tileWidth));
startRow = Math.max(0, Math.floor((bounds.bottom - mapY) / tileHeight));
endRow = Math.min(height, Math.ceil((bounds.top - mapY) / tileHeight));
}
return { startCol, endCol, startRow, endRow };
}
/**
* Create empty render data
* 创建空的渲染数据
*/
private createEmptyRenderData(
entityId: number,
layerIndex: number,
sortingLayer: string,
orderInLayer: number,
sortingOrder: number,
materialId?: number
): TilemapRenderData {
return {
entityId,
layerIndex,
transforms: new Float32Array(0),
textureIds: new Uint32Array(0),
uvs: new Float32Array(0),
colors: new Uint32Array(0),
tileCount: 0,
sortingLayer,
orderInLayer: orderInLayer + layerIndex,
sortingOrder: sortingOrder + layerIndex * 0.001,
materialId: materialId ?? 0
};
}
protected override onRemoved(entity: Entity): void {
// Remove all cached layer data for this entity
// 移除此实体的所有缓存图层数据
const keysToDelete: LayerCacheKey[] = [];
for (const key of this._layerRenderDataCache.keys()) {
if (key.startsWith(`${entity.id}_`)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this._layerRenderDataCache.delete(key);
}
}
/**
* Clear all cached render data
* 清除所有缓存的渲染数据
*/
clearCache(): void {
this._layerRenderDataCache.clear();
this._currentFrameData = [];
}
}

View File

@@ -0,0 +1,27 @@
/**
* Tilemap 模块服务令牌
* Tilemap module service tokens
*
* 定义 tilemap 模块导出的服务令牌。
* Defines service tokens exported by tilemap module.
*/
import { createServiceToken } from '@esengine/ecs-framework';
import type { TilemapRenderingSystem } from './systems/TilemapRenderingSystem';
import type { TilemapPhysicsSystem } from './physics/TilemapPhysicsSystem';
// ============================================================================
// Tilemap 模块导出的令牌 | Tokens exported by Tilemap module
// ============================================================================
/**
* Tilemap 渲染系统令牌
* Tilemap rendering system token
*/
export const TilemapSystemToken = createServiceToken<TilemapRenderingSystem>('tilemapSystem');
/**
* Tilemap 物理系统令牌
* Tilemap physics system token
*/
export const TilemapPhysicsSystemToken = createServiceToken<TilemapPhysicsSystem>('tilemapPhysicsSystem');

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,68 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"lib": [
"ES2020",
"DOM"
],
"outDir": "./bin",
"rootDir": "./src",
"strict": true,
"composite": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"importHelpers": true,
"downlevelIteration": true,
"isolatedModules": false,
"allowJs": true,
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"include": [
"src/**/*",
"plugin.json"
],
"exclude": [
"node_modules",
"bin",
"**/*.test.ts",
"**/*.spec.ts"
],
"references": [
{
"path": "../../framework/core"
},
{
"path": "../../engine/engine-core"
},
{
"path": "../../engine/ecs-engine-bindgen"
},
{
"path": "../../editor/editor-core"
},
{
"path": "../../engine/asset-system"
}
]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import { runtimeOnlyPreset } from '../../tools/build-config/src/presets/plugin-tsup';
export default defineConfig({
...runtimeOnlyPreset(),
tsconfig: 'tsconfig.build.json'
});