diff --git a/assets/scene/main.composite b/assets/scene/main.composite new file mode 100644 index 0000000..c351c12 --- /dev/null +++ b/assets/scene/main.composite @@ -0,0 +1,370 @@ +{ + "version": 1, + "components": [ + { + "name": "inspector::SceneMetadata", + "jsonSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "string", + "serializationType": "utf8-string" + } + }, + "description": { + "type": "string", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "string", + "serializationType": "utf8-string" + } + }, + "thumbnail": { + "type": "string", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "string", + "serializationType": "utf8-string" + } + }, + "ageRating": { + "type": "string", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "string", + "enum": [ + "T", + "A" + ], + "default": "T", + "serializationType": "enum-string", + "enumObject": { + "Teen": "T", + "Adult": "A" + } + } + }, + "categories": { + "type": "array", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "art", + "game", + "casino", + "social", + "music", + "fashion", + "crypto", + "education", + "shop", + "business", + "sports" + ], + "default": "game", + "serializationType": "enum-string", + "enumObject": { + "ART": "art", + "GAME": "game", + "CASINO": "casino", + "SOCIAL": "social", + "MUSIC": "music", + "FASHION": "fashion", + "CRYPTO": "crypto", + "EDUCATION": "education", + "SHOP": "shop", + "BUSINESS": "business", + "SPORTS": "sports" + } + }, + "serializationType": "array" + } + }, + "author": { + "type": "string", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "string", + "serializationType": "utf8-string" + } + }, + "email": { + "type": "string", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "string", + "serializationType": "utf8-string" + } + }, + "tags": { + "type": "array", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "array", + "items": { + "type": "string", + "serializationType": "utf8-string" + }, + "serializationType": "array" + } + }, + "layout": { + "type": "object", + "properties": { + "base": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "serializationType": "int32" + }, + "y": { + "type": "integer", + "serializationType": "int32" + } + }, + "serializationType": "map" + }, + "parcels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "serializationType": "int32" + }, + "y": { + "type": "integer", + "serializationType": "int32" + } + }, + "serializationType": "map" + }, + "serializationType": "array" + } + }, + "serializationType": "map" + }, + "silenceVoiceChat": { + "type": "boolean", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "boolean", + "serializationType": "boolean" + } + }, + "disablePortableExperiences": { + "type": "boolean", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "boolean", + "serializationType": "boolean" + } + }, + "spawnPoints": { + "type": "array", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "serializationType": "utf8-string" + }, + "default": { + "type": "boolean", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "boolean", + "serializationType": "boolean" + } + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "object", + "properties": { + "single": { + "type": "integer", + "serializationType": "int32" + }, + "range": { + "type": "array", + "items": { + "type": "integer", + "serializationType": "int32" + }, + "serializationType": "array" + } + }, + "serializationType": "one-of" + }, + "y": { + "type": "object", + "properties": { + "single": { + "type": "integer", + "serializationType": "int32" + }, + "range": { + "type": "array", + "items": { + "type": "integer", + "serializationType": "int32" + }, + "serializationType": "array" + } + }, + "serializationType": "one-of" + }, + "z": { + "type": "object", + "properties": { + "single": { + "type": "integer", + "serializationType": "int32" + }, + "range": { + "type": "array", + "items": { + "type": "integer", + "serializationType": "int32" + }, + "serializationType": "array" + } + }, + "serializationType": "one-of" + } + }, + "serializationType": "map" + }, + "cameraTarget": { + "type": "object", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "serializationType": "int32" + }, + "y": { + "type": "integer", + "serializationType": "int32" + }, + "z": { + "type": "integer", + "serializationType": "int32" + } + }, + "serializationType": "map" + } + } + }, + "serializationType": "map" + }, + "serializationType": "array" + } + } + }, + "serializationType": "map" + }, + "data": { + "0": { + "json": { + "name": "Test Scene", + "description": "This is a test scene", + "thumbnail": "assets/scene/thumbnail.png", + "ageRating": "T", + "categories": [], + "author": "", + "email": "", + "tags": [], + "layout": { + "base": { + "x": 0, + "y": 0 + }, + "parcels": [ + { + "x": 0, + "y": 0 + } + ] + } + } + } + } + }, + { + "name": "inspector::Nodes", + "jsonSchema": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity": { + "type": "integer", + "serializationType": "entity" + }, + "open": { + "type": "boolean", + "serializationType": "optional", + "optionalJsonSchema": { + "type": "boolean", + "serializationType": "boolean" + } + }, + "children": { + "type": "array", + "items": { + "type": "integer", + "serializationType": "entity" + }, + "serializationType": "array" + } + }, + "serializationType": "map" + }, + "serializationType": "array" + } + }, + "serializationType": "map" + }, + "data": { + "0": { + "json": { + "value": [ + { + "entity": 0, + "children": [], + "open": true + }, + { + "entity": 1, + "children": [] + }, + { + "entity": 2, + "children": [] + } + ] + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/coinshop.ts b/src/coinshop.ts index cd4dbd9..50e82e7 100644 --- a/src/coinshop.ts +++ b/src/coinshop.ts @@ -1,7 +1,21 @@ -import { type Entity, Transform, engine, type TransformType } from '@dcl/sdk/ecs' -import { Vector3, Quaternion } from '@dcl/sdk/math' +/* eslint-disable @typescript-eslint/dot-notation */ +import { + type Entity, + Transform, + engine, + type TransformType, + type Vector3Type, + type PBTextShape, + TextShape, + TextAlignMode +} from '@dcl/sdk/ecs' +import { Vector3, Quaternion, Color4 } from '@dcl/sdk/math' import { ProjectLoader } from './projectloader' -import { type ShopItem } from './shopitem' +import { ShopItem } from './shopitem' +import { som } from './som' +import { type ShopItemInstance } from './projectdata' +import { ItemIcons } from './enums' +import { ItemAmountPanel } from './ui/itemamountpanel' export type BuildingData = { filename: string @@ -9,6 +23,17 @@ export type BuildingData = { angles: [number, number, number] // assuming angles is an array of 3 numbers } +export type PriceData = { + VirtualCurrencyPrices?: { + MA?: number + } +} + +export type StoreItem = { + ItemId: string + // Add any other properties that are expected in the store item +} + export class CoinShop { private readonly entity = engine.addEntity() public trans: TransformType = { position: Vector3.create(), scale: Vector3.create(), rotation: Quaternion.create() } @@ -23,7 +48,7 @@ export class CoinShop { public productModelFile: string = '' public products: ShopItem[] = [] - public storeData: object[] = [] + public storeData: StoreItem[] = [] public textureFile: string = 'models/textures/resources_atlas_1024.png' public txInProgress: boolean = false @@ -56,4 +81,131 @@ export class CoinShop { this.signEntity = ProjectLoader.instance.spawnSceneObject(_signData, true) Transform.getOrCreateMutable(this.signEntity).parent = this.entity } + + getItemData(itemId: string): PriceData | null { + let item + for (let i: number = 0; i < this.storeData.length; i++) { + item = this.storeData[i] + if (item.ItemId === itemId) { + return item as PriceData // Se asegura de que el item tiene el tipo PriceData + } + } + return null + } + + loadProducts(): void { + // log("CoinShop.loadProducts()"); + // log(this.storeData); + const itemNames: string[] = ['CoinPackB', 'CoinPackC', 'CoinPackD', 'CoinPackE', 'CoinPackF'] + let item: ShopItem + let itemName: string + for (let i: number = 0; i < itemNames.length; i++) { + itemName = itemNames[i] + const baseItem = som.shop[itemName] + + if (this.storeData.length > 0) { + // override with server prices if we have them + const priceData = this.getItemData(itemName) + if (priceData?.VirtualCurrencyPrices != null) { + const price = priceData.VirtualCurrencyPrices?.MA + if (price != null && price > 0) { + baseItem.manaPrice = price + } + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + item = new ShopItem(baseItem) + item.instanceData.itemId = itemName + // log("loading " + itemNames[i]); + item.enabled = true + Transform.getMutable(item.entity).parent = this.entity + this.addProduct(item) + // this.products[itemName] = item; + this.addPriceSign(item) + } + } + + addProduct(product: ShopItem): void { + product.onClickCallback = (itemData: ShopItemInstance): boolean => { + console.log('click callback:', itemData) + return this.onProductClicked(itemData) // Ensure `onProductClicked` only uses itemData + } + } + + addPriceSign(item: ShopItem): void { + const sign1Entity = engine.addEntity() + Transform.create(sign1Entity, { + position: Vector3.create(-0.9, -3.2, 0), + scale: Vector3.create(1.8, 1.8, 1.8), + rotation: Quaternion.fromEulerDegrees(0, 90, 0), + parent: item.modelEntity + }) + let fontSize: number = 1.4 + if (item.instanceData.manaPrice >= 1000) { + fontSize = 1.1 + } + const itemTile1 = new ItemAmountPanel( + sign1Entity, + Vector3.create(0, 2, 0.1), + '#333333', + '773344', + this.textureFile, + 8, + 8, + ItemIcons.Mana, + fontSize, + false + ) + if (item.instanceData.manaPrice >= 1000) { + // shift text to the left a bit + const textTrans = Transform.get(itemTile1.textEntity) + const tPos: Vector3Type = textTrans.position + tPos.x = tPos.x - 0.04 + Transform.getMutable(itemTile1.textEntity).position = tPos + } + // itemTile1.showText("1000"); + itemTile1.showText(item.instanceData.manaPrice.toString()) + + const ts: PBTextShape = this.addTextField( + 2, + Vector3.create(-0.35, 1.25, 0), + Vector3.create(0, 90, 0), + item.modelEntity + ) + ts.text = item.instanceData.itemQty.toString() + } + + onProductClicked(itemData: ShopItemInstance, hitPoint?: Vector3): boolean { + return false + } + + addTextField(_fontSize: number, _pos: Vector3, _angles: Vector3, _parent: Entity): PBTextShape { + // create text shape + const ent: Entity = engine.addEntity() + TextShape.create(ent, { + text: '' + }) + const ts: PBTextShape = TextShape.getMutable(ent) + + // log("created textShape"); + + ts.fontSize = _fontSize + // ts.color = Color3.White(); + ts.textColor = Color4.fromHexString('#221100') + + ts.width = 80 + ts.height = 40 + + ts.textAlign = TextAlignMode.TAM_TOP_CENTER + // ts.fontWeight = "bold"; + + ts.textWrapping = false + Transform.create(ent, { + position: _pos, + scale: Vector3.create(1, 1, 1), + rotation: Quaternion.fromEulerDegrees(_angles.x, _angles.y, _angles.z), + parent: _parent + }) + return ts + } } diff --git a/src/gamemanager.ts b/src/gamemanager.ts index ad6ba5f..3b50784 100644 --- a/src/gamemanager.ts +++ b/src/gamemanager.ts @@ -64,5 +64,6 @@ export class GameManager { loadShop():void { // log("loadShop()"); this.shop = new CoinShop(som.scene.cart, som.scene.cartSign) + this.shop.loadProducts(); } } diff --git a/src/shopitem.ts b/src/shopitem.ts index 5a31daa..f380bb4 100644 --- a/src/shopitem.ts +++ b/src/shopitem.ts @@ -29,7 +29,7 @@ export class ShopItem { public enabled: boolean = false - public onClickCallback: ((m: ShopItemInstance) => boolean) | undefined + public onClickCallback: ((itemData: ShopItemInstance, hitPoint?: Vector3) => boolean) | undefined /** * Create a ShopItem given a ShopItemInstance data object. @@ -103,18 +103,20 @@ export class ShopItem { idle(): void { // log("ShopItem.onIdle()"); this.stopAnimations() - - if (this.idleAnim != null) { - Animator.playSingleAnimation(this.entity, this.idleAnim) + if (Animator.getMutableOrNull(this.entity) != null) { + if (this.idleAnim != null) { + Animator.playSingleAnimation(this.entity, this.idleAnim) + } } } playAnimation(): void { // log("ShopItem.onIdle()"); this.stopAnimations() - - if (this.idleAnim != null) { - Animator.playSingleAnimation(this.entity, this.idleAnim) + if (Animator.getMutableOrNull(this.entity) != null) { + if (this.idleAnim != null) { + Animator.playSingleAnimation(this.entity, this.idleAnim) + } } } @@ -131,8 +133,10 @@ export class ShopItem { } stopAnimations(): void { - if (this.idleAnim != null) { - Animator.getClip(this.entity, this.idleAnim).playing = false + if (Animator.getMutableOrNull(this.entity) != null) { + if (this.idleAnim != null) { + Animator.getClip(this.entity, this.idleAnim).playing = false + } } } diff --git a/src/ui/colorplane.ts b/src/ui/colorplane.ts new file mode 100644 index 0000000..2684352 --- /dev/null +++ b/src/ui/colorplane.ts @@ -0,0 +1,69 @@ +import { Billboard, BillboardMode, engine, Material, MeshCollider, MeshRenderer, Transform } from '@dcl/sdk/ecs' +import { Color4, Quaternion, Vector3 } from '@dcl/sdk/math' + +/** + * A simple PlaneShape with a solid color texture. + */ +export class ColorPlane { + // public shape: PlaneShape; + // public material: Material; + public entity = engine.addEntity() + + /** + * Creates a new PlaneShape, sets its color material, and adds it to the engine. + * + * Example: + * let bgPlane = new ColorPlane("#282828", new Vector3(2, 1, 2), new Vector3(0.6, 0.6, 0.1), Vector3.Zero()); + * + * @param _hexColor A hex string describing a Color3 + * @param _pos The x,y,z position in the scene + * @param _scale The x,y,z scale. Since this is a plane only x and y really matter. + * @param _angles The rotation of the plane in terms or Euler angles. + * @param _isBillboard Whether to make this a billboard that always faces the user. + */ + constructor( + _hexColor: string = '#555555', + _pos: Vector3 = Vector3.Zero(), + _scale: Vector3 = Vector3.One(), + _angles: Vector3 = Vector3.Zero(), + _isBillboard: boolean = false + ) { + MeshRenderer.setPlane(this.entity) + MeshCollider.setPlane(this.entity) + + Transform.createOrReplace(this.entity,{position: _pos,scale: _scale, rotation: Quaternion.fromEulerDegrees(_angles.x,_angles.y,_angles.z)}) + + this.changeHexColor(_hexColor) + + if (_isBillboard) { + Billboard.create(this.entity, { billboardMode: BillboardMode.BM_Y }) + } + } + + changeColor(_color: Color4): void { + if (Material.getMutableOrNull(this.entity)!= null){ + Material.deleteFrom(this.entity) + } + Material.setPbrMaterial(this.entity, { + albedoColor: _color, + roughness: 0.9, + specularIntensity: 0, + }) + } + + changeHexColor(_hexColor: string): void { + this.changeColor(Color4.fromHexString(_hexColor)) + } + + hide(): void { + if (Material.getMutableOrNull(this.entity)!= null){ + // add hide logics + } + } + + show(): void { + if (Material.getMutableOrNull(this.entity)!= null){ + // add show logics + } + } +} diff --git a/src/ui/itemamountpanel.ts b/src/ui/itemamountpanel.ts new file mode 100644 index 0000000..e40bbab --- /dev/null +++ b/src/ui/itemamountpanel.ts @@ -0,0 +1,148 @@ +import { engine, type Entity, TextAlignMode, TextShape, Transform } from '@dcl/sdk/ecs' +import { Vector3, Color3, Color4, Quaternion } from '@dcl/sdk/math' +import { ColorPlane } from './colorplane' +import { SpritePlane } from './spriteplane' + +export class ItemAmountPanel { + /* extends Entity */ + /** + * Combines background ColorPlanes wwith a SpritePlane and TextField. + */ + public parentEntity: Entity + public bgPlane: ColorPlane + public icon: SpritePlane + public textEntity: Entity + public textField: string = '' + + public enabledColor: Color3 + public enabledHexColor: string = '#449955' + public disabledColor: Color3 + public disabledHexColor: string = '#333333' + + constructor( + _parent: Entity, + _pos: Vector3 = Vector3.Zero(), + _disabledColor: string = '#333333', + _enabledColor: string = '#449955', + _textureFile: string = '', + _framesX: number = 1, + _framesY: number = 1, + _frameNum: number = 0, + _fontSize: number = 4, + _isBlocker: boolean = false, + _isSmall: boolean = false, + _isBillboard: boolean = false + ) { + // super(); + this.parentEntity = _parent + + // Note: It doesn't seem like Transform apply hierarchically -- only the parent transform seems to matter for scale anyway + // let trans:Transform = new Transform({ position: _pos, scale: Vector3.One() }); + // trans.rotation.eulerAngles = Vector3.create(0, 0, -90); + // this.addComponent(trans); + let bgSize: Vector3 = Vector3.create(0.36, 0.15, 0.1) + let iconScale: number = 0.14 + if (_isSmall) { + bgSize = Vector3.create(0.32, 0.12, 0.1) + iconScale = 0.1 + } + this.bgPlane = new ColorPlane(_disabledColor, Vector3.create(_pos.x, _pos.y, _pos.z), bgSize) + Transform.getMutable(this.bgPlane.entity).parent = _parent + + // let t = this.bgPlane.getComponent(Transform); + // t.scale = Vector3.create(0.5, 0.2, 1); + + this.disabledHexColor = _disabledColor + this.disabledColor = Color3.fromHexString(_disabledColor) + this.enabledHexColor = _enabledColor + this.enabledColor = Color3.fromHexString(_enabledColor) + + // 2DO: change SpritePlane to subclass Entity + this.icon = new SpritePlane( + _textureFile, + _framesX, + _framesY, + _frameNum, + Vector3.create(_pos.x - 0.09, _pos.y, _pos.z - 0.02), + Vector3.create(iconScale, iconScale, 0.1), + Vector3.create(0, 0, -90) + ) + Transform.getMutable(this.icon.entity).parent = _parent + + // add amount field + this.textEntity = this.addTextField(_fontSize, Vector3.create(_pos.x - 0.02, _pos.y + 0.06, _pos.z - 0.02), _parent) + + // if (_isBillboard) + // { + // this.addComponent(new Billboard(false, true, false)); + // } + + // engine.addEntity(this); + } + + addTextField(_fontSize: number, _pos: Vector3, _parent: Entity): Entity { + // create text shape + const ent: Entity = engine.addEntity() + TextShape.create(ent, { + text: this.textField, + textColor: Color4.White(), + fontSize: _fontSize, + width: 80, + height: 40, + textAlign: TextAlignMode.TAM_TOP_LEFT, + textWrapping: false + }) + + // log("created textShape"); + // ts.fontWeight = "bold"; + Transform.create(ent, { + position: _pos, + scale: Vector3.create(1, 1, 1), + rotation: Quaternion.fromEulerDegrees(0, 0, 0), + parent: _parent + }) + return ent + } + + show(_frameNum: number, _value: number, _numOwned: number = 0): void { + // log("show(" + _frameNum + ", " + _value + ", " + _numOwned) + ")"; + if (_numOwned >= _value) { + this.enable() + } else { + this.disable() + } + + // this.showIcon(); + this.icon.changeFrame(_frameNum) + this.showText(_value.toString()) + } + + showText(_text: string): void { + this.textField = _text + } + + enable(): void { + // log("enable()"); + // this.bgPlane.changeColor(this.enabledColor); + this.bgPlane.changeHexColor(this.enabledHexColor) + } + + disable(): void { + // this.bgPlane.changeColor(this.disabledColor); + this.bgPlane.changeHexColor(this.disabledHexColor) + } + + clear(_clearFrameNum: number = 0): void { + this.disable() + this.textField = '' + this.hideIcon(_clearFrameNum) + } + + hideIcon(_clearFrameNum: number = 0): void { + this.icon.changeFrame(_clearFrameNum) + } + + showIcon(): void { + // this.icon.alive = true; + } +} diff --git a/src/ui/spriteplane.ts b/src/ui/spriteplane.ts new file mode 100644 index 0000000..8f0e188 --- /dev/null +++ b/src/ui/spriteplane.ts @@ -0,0 +1,119 @@ +import { Billboard, BillboardMode, engine, Material, MeshCollider, MeshRenderer, Transform } from '@dcl/sdk/ecs' +import { Quaternion, Vector3 } from '@dcl/sdk/math' + +// @Component("SpritePlane") +export class SpritePlane { + /** + * A simple PlaneShape that uses a texture extracted from an atlas image. + */ + public static cache: object = {} // simple object-based texture dictionary + + public entity = engine.addEntity() + // public shape: PlaneShape; + // public material: Material; + public fileName: string = '' + public framesX: number = 0 + public framesY: number = 0 + public totalFrames: number = 0 + // The current frame being shown + public currentFrame: number = 0 + // Defines the frame to be shown when not animating + public frameNum: number = 0 + + public uvs: number[] = [] + + constructor( + _textureFile: string, + _framesX: number = 1, + _framesY: number = 1, + _frameNum: number = 0, + _pos: Vector3 = Vector3.Zero(), + _scale: Vector3 = Vector3.One(), + _angles: Vector3 = Vector3.Zero(), + _isBillboard: boolean = false + ) { + MeshRenderer.setPlane(this.entity, this.uvs) + MeshCollider.setPlane(this.entity) + + Transform.createOrReplace(this.entity, { + position: _pos, + scale: _scale, + rotation: Quaternion.fromEulerDegrees(_angles.x, _angles.y, _angles.z) + }) + + this.changeTexture(_textureFile, _framesX, _framesY, _frameNum) + if (_isBillboard) { + Billboard.create(this.entity, { billboardMode: BillboardMode.BM_Y }) + } + } + + getUVs(_frameNum: number, _framesX: number, _framesY: number): number[] { + if (_framesX <= 0) _framesX = 1 + if (_framesY <= 0) _framesY = 1 + + var xUnit = 1 / _framesX + var yUnit = 1 / _framesY + + const y: number = _framesY - Math.floor(_frameNum / _framesX) - 1 + const x: number = _frameNum % _framesX + // log("frameNum=" + _frameNum + ", x=" + x + ", y=" + y); + + const uvs = [ + // bottom left + x * xUnit, + y * yUnit, + // top left + x * xUnit, + yUnit + y * yUnit, + // top right + xUnit + x * xUnit, + yUnit + y * yUnit, + // bottom right + xUnit + x * xUnit, + y * yUnit + ] + // log("uvs=" + JSON.stringify(uvs)); + return uvs + } + + changeTexture(_textureFile: string, _framesX: number = 1, _framesY: number = 1, _frameNum: number = 0): void { + this.fileName = _textureFile + + // check cache to see if shape is already there + // log("loading " + _textureFile); + + SpritePlane.cache = [_textureFile] + if (Material.getMutableOrNull(this.entity) === null) { + Material.setPbrMaterial(this.entity, { + texture: Material.Texture.Common({ + src: _textureFile + }), + transparencyMode: 2, + roughness: 0.9 + }) + // this.material.emissiveTexture = _textureFile; + // this.material.emissiveIntensity = 0.5; + // this.material.reflectionColor = Color3.White(); + // this.material.emissiveColor = _emissiveColor; + SpritePlane.cache = [_textureFile] + } + + // change UVs + this.framesX = _framesX + if (this.framesX <= 0) this.framesX = 1 + + this.framesY = _framesY + if (this.framesY <= 0) this.framesY = 1 + + this.totalFrames = _framesX * _framesY + + this.changeFrame(_frameNum) + + Material.createOrReplace(this.entity) + } + + changeFrame(_frameNum: number): void { + this.frameNum = _frameNum + this.uvs = this.getUVs(_frameNum, this.framesX, this.framesY) + } +}