ecs-lite 源码简单分析
初学typescript,分析的不到位欢迎指正。
ecs-lite
基于 ts 实现的纯 ecs 库,可用于学习交流及 H5 游戏开发!
https://gitee.com/aodazhang/ecs-lite?_from=gitee_search
文档
https://aodazhang.com/ecs-lite/
api
https://aodazhang.com/ecs-lite/api.html#world-%E4%B8%96%E7%95%8C
几个关键点及新学到的知识点
public createEntity(name?: string): Entity { /** * 处理顺序 O(1) * 1.实体-组件映射 * 2.实体名-实体映射 */ const entity = new Entity( this.entityId.next().value, isString(name) ? name : null ) /** 实体-组件映射 */ //private readonly entityComponents: Map<Entity, ComponentMap> = new Map() //export class ComponentMap extends Map<ComponentConstructor, Component> /** [类型]组件构造函数 */ // export type ComponentConstructor<T extends Component = Component> = new ( // ...rest: any[] // ) => T this.entityComponents.set(entity, new ComponentMap()) if (!isString(entity.name)) { return entity } // /** 实体名-实体映射 */ // private readonly nameEntities: Map<string, EntitySet> = new Map() this.nameEntities.has(entity.name) ? this.nameEntities.get(entity.name).add(entity) : this.nameEntities.set(entity.name, new EntitySet([entity])) return entity }
其中
ComponentMap 较为复杂
/** * 新增实体关联的组件 * @param entity 实体 * @param components 组件 * @returns 世界实例 */ public addEntityWithComponents( entity: Entity, ...components: Component[] ): World { /** * 处理顺序 O(n) * 1.实体-组件映射 * 2.组件-实体映射 */ const componentMap = this.entityComponents.get(entity) if (!componentMap) { return this } componentMap.add(...components) for (const constructor of componentMap.keys()) { this.componentEntities.has(constructor) ? this.componentEntities.get(constructor).add(entity) : this.componentEntities.set(constructor, new EntitySet([entity])) } return this }
主要关键是3个结构的理解
/** 实体名-实体映射 */ private readonly nameEntities: Map<string, EntitySet> = new Map() /** 实体-组件映射 */ private readonly entityComponents: Map<Entity, ComponentMap> = new Map() /** 组件-实体映射 */ private readonly componentEntities: Map<ComponentConstructor, EntitySet> = new Map()
/** * 更新主循环 * @returns 无 */ private update = (): void => { if (this.stop === true) { window.cancelAnimationFrame(this.timer) return } const now = performance.now() /* performance.now是浏览器(Web API)提供的方法,不同浏览器获取到的精度不同。Date.now是Javascript内置方法, 差异主要在于浏览器遵循的ECMAScript规范。 Date.now() 方法返回自 1970 年 1 月 1 日 00:00:00 (UTC) 到当前时间的毫秒数。 performance.now() 方法返回一个精确到毫秒的时间戳,个别浏览器返回的时间戳没有被限制在一毫秒的精确度内, 以浮点数的形式表示时间,精度最高可达微秒级,因此测试时如果需要比毫秒更高的精度,可以使用这个方法。 示例(以chrome为例) const t1 = performance.now() // 538253.3999999762 const t2 = Date.now() // 1664162107633 Date.now() ≈ performance.timing.navigationStart + performance.now() const t1 = performance.timing.navigationStart + performance.now() const t2 = Date.now(); console.log(t2, t1); // 1664162454515 1664162454515.9 */ const frame = Math.max(now - this.time, 0) this.systems.forEach(item => item.update(this, frame)) this.timer = window.requestAnimationFrame(this.update) /** * 既然setInterval可以搞定为啥还要用requestAnimationFrame呢? * 不同之处在于,setInterval必须指定多长时间再执行,window.requestAnimationFrame() * 则是推迟到浏览器下一次重绘时就执行回调。重绘通常是 16ms 执行一次,不过浏览器会自动调节这个速率, * 比如网页切换到后台 Tab 页时,requestAnimationFrame()会暂停执行。 * 如果某个函数会改变网页的布局,一般就放在window.requestAnimationFrame()里面执行, * 这样可以节省系统资源,使得网页效果更加平滑。因为慢速设备会用较慢的速率重流和重绘,而速度更快的设备会有更快的速率。 */ this.time = now } /** * 启动主循环 * @returns 世界实例 */ public start(): World { this.stop = false this.timer = window.requestAnimationFrame(this.update) this.time = performance.now() return this }
/** [抽象类]系统 */ export abstract class System { /** * 更新钩子 * @param world 世界实例 * @param frame 帧渲染时间 * @returns 无 */ public abstract update(world: World, frame?: number): void
我们在world 的update 中每帧中调用system的update方法
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
终于进入正题了,进入example-ecs 目录,这里才是实现的地方
const world = new World({ canvas, // 绘图元素 map, // 资源仓库 isGameOver: false, // 是否游戏结束 speed: 2, // 游戏速度 gap: canvas.height / 4 // 障碍物间隔 }) world.addSystem(new EventSystem(canvas, world)) world.addSystem(new ObstacleSystem(canvas)) world.addSystem(new VelocitySystem(canvas)) world.addSystem(new RotateSystem()) world.addSystem(new CollisionSystem()) world.addSystem(new ScoreSystem()) world.addSystem(new RenderSystem(ctx)) createScene(world) world.start()
先看具体的实体,在createScene 中可以找到
export function createScene(world: World): void { createBackground(world) createFloor(world) createScore(world) createBird(world) } /** * 创建背景 * @param world 世界实例 * @returns 无 */ export function createBackground(world: World): void { const { canvas, sprites, spritesData } = getWorldData(world) world.addEntityWithComponents( world.createEntity('background'), new Image(sprites, [ { sx: spritesData.background.sx, sy: spritesData.background.sy, sw: spritesData.background.sw, sh: spritesData.background.sh } ]), new Position(0, 0), new Size(canvas.width, canvas.height), new Render('image') ) }
看一个简单的组件
再看事件系统
export class EventSystem extends System { constructor( private readonly canvas: HTMLCanvasElement, private readonly world: World ) { super() this.canvas.addEventListener('touchstart', this.onClickCanvas) this.canvas.addEventListener('click', this.onClickCanvas) } public update(): void {}
这个啥都没干,再 找一个
/** * 速度系统 * @description 计算物体的x、y轴运动速度 */ export class VelocitySystem extends System { constructor(private readonly canvas: HTMLCanvasElement) { super() } public update(world: World): void { for (const [entity, componentMap] of world.view(Position, Size, Velocity)) { const position = componentMap.get(Position) const size = componentMap.get(Size) const velocity = componentMap.get(Velocity) velocity.vy += velocity.g position.x += velocity.vx position.y += velocity.vy if (entity.name === 'floor') { // 地板循环向左移动:只要 x坐标 < 视口宽度 - 地板图宽度 则 地板图会和背景出现间隙 因此重置为0 if (position.x < this.canvas.width - size.w) { position.x = 0 } } } } public destory(): void {} }
用一句白话总结一下,
速度 system 在每帧对 拥有 Position, Size, Velocity 组件的实体进行处理,修改了组件的position
组件数据如何跟现实实体数据关联。请看如下
const world = new World({ canvas, // 绘图元素 map, // 资源仓库 isGameOver: false, // 是否游戏结束 speed: 2, // 游戏速度 gap: canvas.height / 4 // 障碍物间隔 })
在world 的构造函数里面,设置了 现实数据
再看一个非常简单的实体
/** * 创建计分器 * @param world 世界实例 * @returns 无 */ export function createScore(world: World): void { const { canvas } = getWorldData(world) world.addEntityWithComponents( world.createEntity('score'), new Text('得分:0', 1000), new Score(0), new Position(10, canvas.width / 10), new Render('text', 1) ) }
这个简单的计分器 实体用到了 4个 组件
看看有几个system 对其处理了
public update(world: World): void { const score = world.findNameWithEntities('score')[0] if (!score) { return } const componentMap = world.findEntityWithComponents(score) const text = componentMap.get(Text) const gameScore = componentMap.get(Score) text.text = `【ECS实现】得分:${gameScore.gameScore++}` }
RenderSystem
export class RenderSystem extends System { constructor(private readonly context: CanvasRenderingContext2D) { super() } public update(world: World): void { // [路径管理]1.清除上一次绘制区域 this.context.clearRect( 0, 0, this.context.canvas.width, this.context.canvas.height ) // 根据渲染层级生成渲染队列 const renderQueue = world.view(Render).sort((a, b) => { const [, componentMapA] = a const [, componentMapB] = b const renderA = componentMapA.get(Render) const renderB = componentMapB.get(Render) return renderA.zindex - renderB.zindex }) // 渲染队列执行渲染 for (const [, componentMap] of renderQueue) { const render = componentMap.get(Render) const position = componentMap.get(Position) const rotate = componentMap.get(Rotate) const image = componentMap.get(Image) const size = componentMap.get(Size) const text = componentMap.get(Text) // [路径管理]2.保存当前上下文状态 this.context.save() // [路径变换]3.设置变换(位移、旋转、缩放) const mx = position?.x + size?.w / 2 const my = position?.y + size?.h / 2 this.context.translate(mx, my) // canvas旋转是基于绘图区域原点的,因此需要调整到精灵原点 this.context.rotate(rotate?.angle) this.context.translate(-mx, -my) // 旋转后再次平移回来 if (render.type === 'image') { image.clipCount += image.clipSpeed image.clipCount >= image.clips.length - 1 && (image.clipCount = 0) const clip = image.clips[Math.floor(image.clipCount)] // [路径绘制]5.执行图片绘制 this.context.drawImage( image?.source, clip?.sx, clip?.sy, clip?.sw, clip?.sh, position?.x, position?.y, size?.w, size?.h ) } else if (render.type === 'text') { // [路径样式]4.设置样式(尺寸、颜色、阴影、字体) this.context.font = text.font this.context.fillStyle = text.fillStyle // [路径绘制]5.执行文本绘制 this.context.fillText(text.text, position.x, position.y, text.maxWidth) } // [路径管理]6.恢复上一次上下文状态 this.context.restore() } } public destory(): void {} }
哈哈,先清屏,我们的计分器在这里
} else if (render.type === 'text') {
// [路径样式]4.设置样式(尺寸、颜色、阴影、字体)
this.context.font = text.font
this.context.fillStyle = text.fillStyle
// [路径绘制]5.执行文本绘制
this.context.fillText(text.text, position.x, position.y, text.maxWidth)
浙公网安备 33010602011771号