feat(tilemap): 增强tilemap编辑器和动画系统
This commit is contained in:
44
packages/tilemap/module.json
Normal file
44
packages/tilemap/module.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "tilemap",
|
||||
"name": "@esengine/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"
|
||||
],
|
||||
"exports": {
|
||||
"components": [
|
||||
"TilemapComponent"
|
||||
],
|
||||
"systems": [
|
||||
"TilemapRenderingSystem"
|
||||
],
|
||||
"loaders": [
|
||||
"TilemapLoader",
|
||||
"TilesetLoader"
|
||||
]
|
||||
},
|
||||
"editorPackage": "@esengine/tilemap-editor",
|
||||
"requiresWasm": false,
|
||||
"outputPath": "dist/index.js",
|
||||
"pluginExport": "TilemapPlugin"
|
||||
}
|
||||
207
packages/tilemap/src/TilemapAnimationSystem.ts
Normal file
207
packages/tilemap/src/TilemapAnimationSystem.ts
Normal 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();
|
||||
@@ -11,6 +11,41 @@ export type ResizeAnchor =
|
||||
| 'middle-left' | 'center' | 'middle-right'
|
||||
| 'bottom-left' | 'bottom-center' | 'bottom-right';
|
||||
|
||||
/**
|
||||
* Animation frame definition
|
||||
* 动画帧定义
|
||||
*/
|
||||
export interface ITileAnimationFrame {
|
||||
/** Tile ID to display for this frame (local ID within tileset) | 此帧显示的瓦片ID(图块集内的本地ID) */
|
||||
tileId: number;
|
||||
/** Frame duration in milliseconds | 帧持续时间(毫秒) */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile animation definition
|
||||
* 瓦片动画定义
|
||||
*/
|
||||
export interface ITileAnimation {
|
||||
/** Animation frame sequence | 动画帧序列 */
|
||||
frames: ITileAnimationFrame[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual tile metadata
|
||||
* 单个瓦片元数据
|
||||
*/
|
||||
export interface ITileMetadata {
|
||||
/** Tile ID (local ID within tileset) | 瓦片ID(图块集内的本地ID) */
|
||||
id: number;
|
||||
/** Tile class/type | 瓦片类型 */
|
||||
type?: string;
|
||||
/** Custom properties | 自定义属性 */
|
||||
properties?: Record<string, unknown>;
|
||||
/** Tile animation (if any) | 瓦片动画(如果有) */
|
||||
animation?: ITileAnimation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tileset data interface
|
||||
* 图块集数据接口
|
||||
@@ -41,11 +76,7 @@ export interface ITilesetData {
|
||||
/** Spacing between tiles in pixels | 图块间距(像素) */
|
||||
spacing?: number;
|
||||
/** Individual tile metadata | 单个图块元数据 */
|
||||
tiles?: Array<{
|
||||
id: number;
|
||||
type?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}>;
|
||||
tiles?: ITileMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +100,14 @@ export interface ITilemapLayerData {
|
||||
offsetX?: number;
|
||||
/** Layer Y offset in pixels | 图层Y偏移(像素) */
|
||||
offsetY?: number;
|
||||
/** Material asset path for this layer (.mat file) | 此图层的材质资源路径(.mat 文件) */
|
||||
materialPath?: string;
|
||||
/** Runtime material ID (set after loading) | 运行时材质ID(加载后设置) */
|
||||
materialId?: number;
|
||||
/** Tint color in hex format | 着色颜色(十六进制格式) */
|
||||
color?: string;
|
||||
/** Hidden in game (visible only in editor) | 游戏中隐藏(仅在编辑器中可见) */
|
||||
hiddenInGame?: boolean;
|
||||
/** Custom layer properties | 自定义图层属性 */
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
@@ -440,6 +479,40 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
||||
return layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a layer
|
||||
* 复制图层
|
||||
* @param index Layer index to duplicate | 要复制的图层索引
|
||||
* @returns The duplicated layer data, or null if index is invalid | 复制的图层数据,如果索引无效则返回 null
|
||||
*/
|
||||
duplicateLayer(index: number): ITilemapLayerData | null {
|
||||
if (index < 0 || index >= this._layers.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceLayer = this._layers[index];
|
||||
const sourceData = this._layersData.get(sourceLayer.id);
|
||||
if (!sourceData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = `layer_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
const newLayer: ITilemapLayerData = {
|
||||
id,
|
||||
name: `${sourceLayer.name} (副本)`,
|
||||
visible: sourceLayer.visible,
|
||||
opacity: sourceLayer.opacity,
|
||||
data: Array.from(sourceData)
|
||||
};
|
||||
|
||||
// Insert after the source layer
|
||||
this._layers.splice(index + 1, 0, newLayer);
|
||||
this._layersData.set(id, new Uint32Array(sourceData));
|
||||
this.renderDirty = true;
|
||||
|
||||
return newLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a layer by index (cannot remove last layer)
|
||||
* 按索引移除图层(不能移除最后一个图层)
|
||||
@@ -527,6 +600,96 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layer color (tint)
|
||||
* 设置图层颜色(着色)
|
||||
*/
|
||||
setLayerColor(index: number, color: string): void {
|
||||
if (index >= 0 && index < this._layers.length) {
|
||||
this._layers[index].color = color;
|
||||
this.renderDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer color
|
||||
* 获取图层颜色
|
||||
*/
|
||||
getLayerColor(index: number): string {
|
||||
if (index >= 0 && index < this._layers.length) {
|
||||
return this._layers[index].color ?? '#ffffff';
|
||||
}
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layer hidden in game
|
||||
* 设置图层在游戏中隐藏
|
||||
*/
|
||||
setLayerHiddenInGame(index: number, hidden: boolean): void {
|
||||
if (index >= 0 && index < this._layers.length) {
|
||||
this._layers[index].hiddenInGame = hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer hidden in game
|
||||
* 获取图层在游戏中是否隐藏
|
||||
*/
|
||||
getLayerHiddenInGame(index: number): boolean {
|
||||
if (index >= 0 && index < this._layers.length) {
|
||||
return this._layers[index].hiddenInGame ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layer material path
|
||||
* 设置图层材质路径
|
||||
* @param index Layer index | 图层索引
|
||||
* @param materialPath Material asset path (.mat file) | 材质资源路径(.mat 文件)
|
||||
*/
|
||||
setLayerMaterial(index: number, materialPath: string): void {
|
||||
if (index >= 0 && index < this._layers.length) {
|
||||
this._layers[index].materialPath = materialPath;
|
||||
this._layers[index].materialId = undefined;
|
||||
this.renderDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer material path
|
||||
* 获取图层材质路径
|
||||
* @param index Layer index | 图层索引
|
||||
* @returns Material path or undefined | 材质路径或 undefined
|
||||
*/
|
||||
getLayerMaterial(index: number): string | undefined {
|
||||
return this._layers[index]?.materialPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set layer material ID (runtime)
|
||||
* 设置图层材质ID(运行时)
|
||||
* @param index Layer index | 图层索引
|
||||
* @param materialId Runtime material ID | 运行时材质ID
|
||||
*/
|
||||
setLayerMaterialId(index: number, materialId: number): void {
|
||||
if (index >= 0 && index < this._layers.length) {
|
||||
this._layers[index].materialId = materialId;
|
||||
this.renderDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer material ID
|
||||
* 获取图层材质ID
|
||||
* @param index Layer index | 图层索引
|
||||
* @returns Material ID or 0 (default) | 材质ID 或 0(默认)
|
||||
*/
|
||||
getLayerMaterialId(index: number): number {
|
||||
return this._layers[index]?.materialId ?? 0;
|
||||
}
|
||||
|
||||
// ===== Tile Operations | 瓦片操作 =====
|
||||
|
||||
/**
|
||||
@@ -591,6 +754,22 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
||||
return this._layersData.get(layer.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set raw tile data array for a layer
|
||||
* 设置图层的原始图块数据数组
|
||||
* @param layerIndex Layer index | 图层索引
|
||||
* @param data Uint32Array of tile indices | 图块索引的Uint32Array
|
||||
*/
|
||||
setLayerData(layerIndex: number, data: Uint32Array): void {
|
||||
const layer = this._layers[layerIndex];
|
||||
if (!layer) return;
|
||||
|
||||
// Copy data to both the layer object and the internal map
|
||||
layer.data = Array.from(data);
|
||||
this._layersData.set(layer.id, new Uint32Array(data));
|
||||
this.renderDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged tile data from all visible layers
|
||||
* 获取所有可见图层合并后的图块数据
|
||||
@@ -1002,6 +1181,18 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有图层材质引用
|
||||
// Collect all layer material references
|
||||
for (const layer of this._layers) {
|
||||
if (layer.materialPath) {
|
||||
refs.push({
|
||||
path: layer.materialPath,
|
||||
type: 'data',
|
||||
runtimeId: layer.materialId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
@@ -1021,6 +1212,17 @@ export class TilemapComponent extends Component implements IResourceComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个图层设置材质 ID
|
||||
// Set material ID for each layer
|
||||
for (const layer of this._layers) {
|
||||
if (layer.materialPath) {
|
||||
const materialId = pathToId.get(layer.materialPath);
|
||||
if (materialId !== undefined) {
|
||||
layer.materialId = materialId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记渲染数据为脏,需要重新构建
|
||||
// Mark render data as dirty, needs rebuild
|
||||
this.renderDirty = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IScene } from '@esengine/ecs-framework';
|
||||
import { ComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { IRuntimeModule, IPlugin, PluginDescriptor, SystemContext } from '@esengine/engine-core';
|
||||
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
|
||||
import type { AssetManager } from '@esengine/asset-system';
|
||||
|
||||
import { TilemapComponent } from './TilemapComponent';
|
||||
@@ -63,18 +63,25 @@ class TilemapRuntimeModule implements IRuntimeModule {
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor: PluginDescriptor = {
|
||||
id: '@esengine/tilemap',
|
||||
name: 'Tilemap',
|
||||
const manifest: ModuleManifest = {
|
||||
id: 'tilemap',
|
||||
name: '@esengine/tilemap',
|
||||
displayName: 'Tilemap 2D',
|
||||
version: '1.0.0',
|
||||
description: 'Tilemap system with Tiled editor support',
|
||||
category: 'tilemap',
|
||||
enabledByDefault: false,
|
||||
isEnginePlugin: true
|
||||
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: IPlugin = {
|
||||
descriptor,
|
||||
manifest,
|
||||
runtimeModule: new TilemapRuntimeModule()
|
||||
};
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ export { TilemapAssetType, TilesetAssetType } from './constants';
|
||||
|
||||
// Component
|
||||
export { TilemapComponent } from './TilemapComponent';
|
||||
export type { ITilemapData, ITilesetData } 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';
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError,
|
||||
IAssetLoader
|
||||
IAssetLoader,
|
||||
IAssetContent,
|
||||
IAssetParseContext,
|
||||
AssetContentType
|
||||
} from '@esengine/asset-system';
|
||||
import { TilemapAssetType } from '../constants';
|
||||
|
||||
@@ -39,6 +38,8 @@ export interface ITilemapAsset {
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
data?: number[];
|
||||
/** 材质路径 */
|
||||
materialPath?: string;
|
||||
}>;
|
||||
/** 碰撞数据(可选) */
|
||||
collisionData?: number[];
|
||||
@@ -53,78 +54,26 @@ export interface ITilemapAsset {
|
||||
export class TilemapLoader implements IAssetLoader<ITilemapAsset> {
|
||||
readonly supportedType = TilemapAssetType;
|
||||
readonly supportedExtensions = ['.tilemap.json', '.tilemap'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Load tilemap asset
|
||||
* 加载瓦片地图资产
|
||||
* Parse tilemap asset from text content
|
||||
* 从文本内容解析瓦片地图资产
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<ITilemapAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const jsonData = await response.json() as ITilemapAsset;
|
||||
|
||||
// 验证必要字段
|
||||
if (!jsonData.width || !jsonData.height || !jsonData.data) {
|
||||
throw new Error('Invalid tilemap format: missing required fields');
|
||||
}
|
||||
|
||||
return {
|
||||
asset: jsonData,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load tilemap: ${error.message}`,
|
||||
metadata.guid,
|
||||
TilemapAssetType,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITilemapAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Tilemap content is empty');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const jsonData = JSON.parse(content.text) as ITilemapAsset;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
// 验证必要字段
|
||||
// Validate required fields
|
||||
if (!jsonData.width || !jsonData.height || !jsonData.data) {
|
||||
throw new Error('Invalid tilemap format: missing required fields');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const lowerPath = path.toLowerCase();
|
||||
return this.supportedExtensions.some(ext => lowerPath.endsWith(ext));
|
||||
return jsonData;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
IAssetLoadOptions,
|
||||
IAssetMetadata,
|
||||
IAssetLoadResult,
|
||||
AssetLoadError,
|
||||
IAssetLoader
|
||||
IAssetLoader,
|
||||
IAssetContent,
|
||||
IAssetParseContext,
|
||||
AssetContentType
|
||||
} from '@esengine/asset-system';
|
||||
import { TilesetAssetType } from '../constants';
|
||||
|
||||
@@ -56,89 +55,38 @@ export interface ITilesetAsset {
|
||||
export class TilesetLoader implements IAssetLoader<ITilesetAsset> {
|
||||
readonly supportedType = TilesetAssetType;
|
||||
readonly supportedExtensions = ['.tileset.json', '.tileset'];
|
||||
readonly contentType: AssetContentType = 'text';
|
||||
|
||||
/**
|
||||
* Load tileset asset
|
||||
* 加载瓦片集资产
|
||||
* Parse tileset asset from text content
|
||||
* 从文本内容解析瓦片集资产
|
||||
*/
|
||||
async load(
|
||||
path: string,
|
||||
metadata: IAssetMetadata,
|
||||
options?: IAssetLoadOptions
|
||||
): Promise<IAssetLoadResult<ITilesetAsset>> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(path, options?.timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const jsonData = await response.json() as ITilesetAsset;
|
||||
|
||||
// 验证必要字段
|
||||
if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) {
|
||||
throw new Error('Invalid tileset format: missing required fields');
|
||||
}
|
||||
|
||||
// 计算派生字段(如果未提供)
|
||||
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 {
|
||||
asset: jsonData,
|
||||
handle: 0,
|
||||
metadata,
|
||||
loadTime: performance.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new AssetLoadError(
|
||||
`Failed to load tileset: ${error.message}`,
|
||||
metadata.guid,
|
||||
TilesetAssetType,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw AssetLoadError.fileNotFound(metadata.guid, path);
|
||||
async parse(content: IAssetContent, _context: IAssetParseContext): Promise<ITilesetAsset> {
|
||||
if (!content.text) {
|
||||
throw new Error('Tileset content is empty');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
* 带超时的fetch
|
||||
*/
|
||||
private async fetchWithTimeout(url: string, timeout = 30000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const jsonData = JSON.parse(content.text) as ITilesetAsset;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
mode: 'cors',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
// 验证必要字段
|
||||
// Validate required fields
|
||||
if (!jsonData.tileWidth || !jsonData.tileHeight || !jsonData.image) {
|
||||
throw new Error('Invalid tileset format: missing required fields');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the loader can handle this asset
|
||||
* 验证加载器是否可以处理此资产
|
||||
*/
|
||||
canLoad(path: string, _metadata: IAssetMetadata): boolean {
|
||||
const lowerPath = path.toLowerCase();
|
||||
return this.supportedExtensions.some(ext => lowerPath.endsWith(ext));
|
||||
// 计算派生字段(如果未提供)
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { EntitySystem, Matcher, ECSSystem, Entity } from '@esengine/ecs-framework';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { Color } from '@esengine/ecs-framework-math';
|
||||
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
|
||||
import { TilemapComponent } from '../TilemapComponent';
|
||||
import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent';
|
||||
|
||||
/**
|
||||
* Tilemap render data for a single tilemap
|
||||
* 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 order for rendering | 渲染排序顺序 */
|
||||
sortingOrder: number;
|
||||
/** Texture path for loading | 纹理路径 */
|
||||
texturePath?: string;
|
||||
/** Material ID for this layer (0 = default) | 此图层的材质ID(0 = 默认) */
|
||||
materialId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,22 +42,40 @@ export interface ViewportBounds {
|
||||
}
|
||||
|
||||
/**
|
||||
* 瓦片地图渲染系统 - 准备瓦片地图渲染数据
|
||||
* 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 {
|
||||
private _renderDataCache: Map<number, TilemapRenderData> = new Map();
|
||||
/** 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;
|
||||
}
|
||||
@@ -61,74 +93,95 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
||||
|
||||
if (!tilemap || !transform || !tilemap.visible) continue;
|
||||
|
||||
let renderData = this._renderDataCache.get(entity.id);
|
||||
// Process each layer separately
|
||||
// 分别处理每个图层
|
||||
const layers = tilemap.layers;
|
||||
for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
|
||||
const layer = layers[layerIndex];
|
||||
if (!layer.visible) continue;
|
||||
|
||||
if (!renderData || tilemap.renderDirty) {
|
||||
renderData = this.buildRenderData(entity.id, tilemap, transform);
|
||||
this._renderDataCache.set(entity.id, renderData);
|
||||
tilemap.renderDirty = false;
|
||||
} else {
|
||||
this.updateTransforms(renderData, tilemap, transform);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private buildRenderData(
|
||||
/**
|
||||
* Build render data for a single layer
|
||||
* 为单个图层构建渲染数据
|
||||
*/
|
||||
private buildLayerRenderData(
|
||||
entityId: number,
|
||||
layerIndex: number,
|
||||
tilemap: TilemapComponent,
|
||||
transform: TransformComponent
|
||||
transform: TransformComponent,
|
||||
layer: ITilemapLayerData
|
||||
): TilemapRenderData {
|
||||
const mergedData = tilemap.getMergedTileData();
|
||||
const layerData = tilemap.getLayerData(layerIndex);
|
||||
if (!layerData) {
|
||||
return this.createEmptyRenderData(entityId, layerIndex, tilemap.sortingOrder, layer.materialId);
|
||||
}
|
||||
|
||||
const width = tilemap.width;
|
||||
const height = tilemap.height;
|
||||
const tileWidth = tilemap.tileWidth;
|
||||
const tileHeight = tilemap.tileHeight;
|
||||
|
||||
// Calculate visible tile range
|
||||
// 计算可见瓦片范围
|
||||
let startCol = 0,
|
||||
endCol = width;
|
||||
let startRow = 0,
|
||||
endRow = height;
|
||||
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
|
||||
width, height, tileWidth, tileHeight, transform
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// 计算非空瓦片数量
|
||||
// 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 (mergedData[row * width + col] > 0) tileCount++;
|
||||
if (layerData[row * width + col] > 0) tileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (tileCount === 0) {
|
||||
return this.createEmptyRenderData(entityId, layerIndex, 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);
|
||||
|
||||
const colorValue = this.parseColor(tilemap.color, tilemap.alpha);
|
||||
// Calculate color with layer opacity
|
||||
// 计算带有图层透明度的颜色
|
||||
const effectiveAlpha = tilemap.alpha * layer.opacity;
|
||||
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
|
||||
|
||||
// 计算旋转参数
|
||||
// Calculate rotation parameters
|
||||
// Note: transform.rotation.z is already in radians (set by Viewport gizmo)
|
||||
// 注意:transform.rotation.z 已经是弧度(由 Viewport gizmo 设置)
|
||||
// 计算旋转参数
|
||||
const cos = Math.cos(transform.rotation.z);
|
||||
const sin = Math.sin(transform.rotation.z);
|
||||
|
||||
// Tilemap 旋转中心点(左下角为原点,中心在 width/2, height/2 处)
|
||||
// Tilemap rotation pivot (origin at bottom-left, center at width/2, height/2)
|
||||
// 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;
|
||||
|
||||
@@ -137,45 +190,43 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
||||
|
||||
for (let row = startRow; row < endRow; row++) {
|
||||
for (let col = startCol; col < endCol; col++) {
|
||||
const gid = mergedData[row * width + col];
|
||||
const gid = layerData[row * width + col];
|
||||
if (gid <= 0) continue;
|
||||
|
||||
// Find corresponding tileset
|
||||
// 查找对应的 tileset
|
||||
const tilesetInfo = tilemap.getTilesetForGid(gid);
|
||||
if (!tilesetInfo) {
|
||||
continue;
|
||||
}
|
||||
if (!tilesetInfo) continue;
|
||||
|
||||
const { index: tilesetIndex, localId } = tilesetInfo;
|
||||
|
||||
// Get texture path
|
||||
// 获取纹理路径
|
||||
if (!texturePath && tilemap.tilesets[tilesetIndex]) {
|
||||
texturePath = tilemap.tilesets[tilesetIndex].source;
|
||||
}
|
||||
|
||||
// 计算瓦片的本地位置(相对于 tilemap 中心)
|
||||
// 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
|
||||
// 应用旋转变换
|
||||
const rotatedX = localX * cos - localY * sin + pivotX;
|
||||
const rotatedY = localX * sin + localY * cos + pivotY;
|
||||
|
||||
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
|
||||
// Each tile rotates the same angle as the tilemap, so the whole map rotates as a unit
|
||||
// 每个 tile 旋转与 tilemap 相同的角度,这样整个地图作为一个整体旋转
|
||||
const tOffset = idx * 7;
|
||||
transforms[tOffset] = rotatedX;
|
||||
transforms[tOffset + 1] = rotatedY;
|
||||
transforms[tOffset + 2] = transform.rotation.z; // Each tile rotates with tilemap
|
||||
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 (使用 tileset 的 textureId)
|
||||
// Texture ID
|
||||
textureIds[idx] = tilemap.tilesets[tilesetIndex]?.textureId || 0;
|
||||
|
||||
// UV coordinates
|
||||
@@ -200,33 +251,106 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
||||
|
||||
return {
|
||||
entityId,
|
||||
layerIndex,
|
||||
transforms,
|
||||
textureIds,
|
||||
uvs,
|
||||
colors,
|
||||
tileCount,
|
||||
sortingOrder: tilemap.sortingOrder,
|
||||
texturePath
|
||||
sortingOrder: tilemap.sortingOrder + layerIndex * 0.001,
|
||||
texturePath,
|
||||
materialId: layer.materialId ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
private updateTransforms(
|
||||
/**
|
||||
* Update transforms for a layer (when only position/rotation/scale changed)
|
||||
* 更新图层的变换(当只有位置/旋转/缩放改变时)
|
||||
*/
|
||||
private updateLayerTransforms(
|
||||
renderData: TilemapRenderData,
|
||||
layerIndex: number,
|
||||
tilemap: TilemapComponent,
|
||||
transform: TransformComponent
|
||||
transform: TransformComponent,
|
||||
layer: ITilemapLayerData
|
||||
): void {
|
||||
const mergedData = tilemap.getMergedTileData();
|
||||
const layerData = tilemap.getLayerData(layerIndex);
|
||||
if (!layerData) return;
|
||||
|
||||
const width = tilemap.width;
|
||||
const height = tilemap.height;
|
||||
const tileWidth = tilemap.tileWidth;
|
||||
const tileHeight = tilemap.tileHeight;
|
||||
|
||||
// 计算可见瓦片范围(与 buildRenderData 保持一致)
|
||||
// Calculate visible tile range (consistent with buildRenderData)
|
||||
let startCol = 0,
|
||||
endCol = width;
|
||||
let startRow = 0,
|
||||
endRow = height;
|
||||
// Calculate visible tile range
|
||||
// 计算可见瓦片范围
|
||||
const { startCol, endCol, startRow, endRow } = this.calculateVisibleRange(
|
||||
width, height, tileWidth, tileHeight, transform
|
||||
);
|
||||
|
||||
// Calculate rotation parameters
|
||||
// 计算旋转参数
|
||||
const cos = Math.cos(transform.rotation.z);
|
||||
const sin = Math.sin(transform.rotation.z);
|
||||
|
||||
// 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
|
||||
// 应用旋转变换
|
||||
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;
|
||||
@@ -239,83 +363,52 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
|
||||
endRow = Math.min(height, Math.ceil((bounds.top - mapY) / tileHeight));
|
||||
}
|
||||
|
||||
// 计算旋转参数
|
||||
// Calculate rotation parameters
|
||||
// Note: transform.rotation.z is already in radians (set by Viewport gizmo)
|
||||
// 注意:transform.rotation.z 已经是弧度(由 Viewport gizmo 设置)
|
||||
const cos = Math.cos(transform.rotation.z);
|
||||
const sin = Math.sin(transform.rotation.z);
|
||||
|
||||
// Tilemap 旋转中心点
|
||||
// Tilemap rotation pivot
|
||||
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 (mergedData[row * width + col] <= 0) continue;
|
||||
|
||||
// 计算瓦片的本地位置(相对于 tilemap 中心)
|
||||
// Calculate tile local position (relative to tilemap center)
|
||||
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
|
||||
const rotatedX = localX * cos - localY * sin + pivotX;
|
||||
const rotatedY = localX * sin + localY * cos + pivotY;
|
||||
|
||||
// Each tile rotates the same angle as the tilemap
|
||||
// 每个 tile 旋转与 tilemap 相同的角度
|
||||
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 may have changed)
|
||||
// 更新颜色(alpha 可能已更改)
|
||||
const colorValue = this.parseColor(tilemap.color, tilemap.alpha);
|
||||
for (let i = 0; i < renderData.colors.length; i++) {
|
||||
renderData.colors[i] = colorValue;
|
||||
}
|
||||
|
||||
renderData.sortingOrder = tilemap.sortingOrder;
|
||||
return { startCol, endCol, startRow, endRow };
|
||||
}
|
||||
|
||||
private parseColor(hex: string, alpha: number): number {
|
||||
const colorHex = hex.replace('#', '');
|
||||
|
||||
let r = 255,
|
||||
g = 255,
|
||||
b = 255;
|
||||
|
||||
if (colorHex.length === 6) {
|
||||
r = parseInt(colorHex.substring(0, 2), 16);
|
||||
g = parseInt(colorHex.substring(2, 4), 16);
|
||||
b = parseInt(colorHex.substring(4, 6), 16);
|
||||
} else if (colorHex.length === 3) {
|
||||
r = parseInt(colorHex[0] + colorHex[0], 16);
|
||||
g = parseInt(colorHex[1] + colorHex[1], 16);
|
||||
b = parseInt(colorHex[2] + colorHex[2], 16);
|
||||
}
|
||||
|
||||
const a = Math.round(alpha * 255);
|
||||
return (a << 24) | (b << 16) | (g << 8) | r;
|
||||
/**
|
||||
* Create empty render data
|
||||
* 创建空的渲染数据
|
||||
*/
|
||||
private createEmptyRenderData(
|
||||
entityId: number,
|
||||
layerIndex: 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,
|
||||
sortingOrder: sortingOrder + layerIndex * 0.001,
|
||||
materialId: materialId ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
protected override onRemoved(entity: Entity): void {
|
||||
this._renderDataCache.delete(entity.id);
|
||||
// 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._renderDataCache.clear();
|
||||
this._layerRenderDataCache.clear();
|
||||
this._currentFrameData = [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user