With the introduction of AIR 3.2, Adobe has brought Stage3d to mobile devices. Finally flash developers can now leverage direct GPU Acceleration on Android and iOS!
While Stage3D obviously enables 3d games, it’s also a huge boon for 2d games! The GPU is extremely fast at pushing around 2D sprites, and there are already several 2d frameworks you can choose from:
Each of these has pro’s and cons, and they all mimic the Flash Display List to some extent. Genome is probably the fastest, but the documentation is light. Starling mimics the DisplayList very closely, and has good API’s and support. ND2D is somewhere in the middle, it still mimics the displayList slightly, but gives you a bit more fine grained control over the rendering operations and options.
My personal favorite right now is ND2D. So I thought it would be cool to take a look at how you might build an Endless Runner style game with this framework!
Files & Demo
Before we get started here’s some downloads to help you follow along:
- In Browser Demo – See what we’ll be building!
- Project Files – FlashBuilder Project
- SimpleRunner.apk – Demo on your Android Device!
Overview
The core classes which will make up this demo are*:
- Game.as – Root display object, instantiates the other classes, and manages property injection
- Background.as – Handle parrallax scrolling bg
- Foreground.as – Handles ground tiles
- PhysicsManager – Simple Physics engine, processes gravity and collisions
- PlayerManager – Control Player animations and Keyboard controls
* Note: I’ve never actually built this type of game before. So, my approach may have some realworld issues I’m not seeing… However, this is about my 3rd iteration, so it should be ok
Hello World2D
The first step to setting up ND2D is to create your World2D, and assign it an active Scene. In this example we’ll have just one Scene, our main Game:
0 1 2 3 4 5 6 7 8 9 | /////////////////////////////////////////////////////////////////////////// public function SimpleRunner() { world2d = new World2D(Context3DRenderMode.AUTO, 60); addChild(world2d); game = new Game(stage); world2d.setActiveScene(game); world2d.start(); } /////////////////////////////////////////////////////////////////////////// |
We’ve initialized the World2D with a frameRate of 60fps, and we’ve set renderMode to AUTO, which means it will use hardware acceleration whenever possible. Finally, we must call start() to actually begin rendering.
Next we need to create our main Game Class. Game.as extends Scene2d, and will serve as the root displayObject for our Game. If World2D is your stage, then Game is “Scene1″ within that stage.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | /////////////////////////////////////////////////////////////////////////// public class Game extends Scene2D { public static var stage:Stage; public static var gameWidth:int; public static var gameHeight:int; public function Game(stage:Stage){ Game.stage = stage; gameWidth = stage.stageWidth; gameHeight = stage.stageHeight; init(); } } /////////////////////////////////////////////////////////////////////////// |
Now we’ve got an empty shell, running at 60fps. Next up lets add some graphics!
Background
For our background, we’re going to use a couple of long images, and scroll them at different speeds to create a parrallax effect. We’ll also use a simple gradient for the main background color.
First, lets create a Background.as class to handle everything. This class can extends Node2d, which is the most lightweight container class available within ND2D.
0 1 2 3 4 5 6 7 8 9 10 11 12 | /////////////////////////////////////////////////////////////////////////// public class Background extends Node2D { protected var gameHeight:int; protected var gameWidth:int; public function Background(width:int, height:int){ gameWidth = width; gameHeight = height; init(); } /////////////////////////////////////////////////////////////////////////// |
Inside of init(), we’ll create a simple gradient texture. To display the Texture will use the Sprite2d class. Sprite2D is similar to a Bitmap in AS3, it’s job is simply to display a rectangular image. But, instead of accepting bitmapData, it requires a Texture2d.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /////////////////////////////////////////////////////////////////////////// public function init():void { //Create a simple gradient background //Draw a rectangle using Graphics, just like normal... var m:Matrix = new Matrix(); m.createGradientBox(64, 64, Math.PI/2); var rect:Sprite = new Sprite(); rect.graphics.beginGradientFill(GradientType.LINEAR, [0x0, 0x1E095E], [1, .5], [0, 255], m); rect.graphics.drawRect(0, 0, 128, 128); //Draw graphics to bitmapData var fillData:BitmapData = new BitmapData(128, 128, false, 0x0); fillData.draw(rect); //Create texture from bitmapData bgFill = new Sprite2D(Texture2D.textureFromBitmapData(fillData)); //Offset pivot point to 0,0, to mimic displayList bgFill.pivot = new Point(-64, -64); //Stretch to fill scene bgFill.width = gameWidth; bgFill.height = gameHeight; addChild(bgFill); } /////////////////////////////////////////////////////////////////////////// |
You’ll notice that we needed to adjust the pivot point manually. By default, ND2D places the registration point for Sprite2d in the center of the object, if you want 0,0 to be topLeft, then you need to offset the pivot point as shown.
Other than that, it’s all very straightforward. You can use the Grapics API to draw anything you like, draw() that into a bitmapData, and then use Texture2D.textureFromBitmapData() to generate your texture. Simple!
All that’s left to do now is initialize the Background, and add it to the current Scene. We’ll do that within the init function of Game.as:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /////////////////////////////////////////////////////////////////////////// public class Game extends Scene2D { public static var stage:Stage; public static var gameWidth:int; public static var gameHeight:int; public var bg:Background; public function Game(stage:Stage){ Game.stage = stage; gameWidth = stage.stageWidth; gameHeight = stage.stageHeight; init(); } public function init():void { bg = new Background(gameWidth, gameHeight); bg.init(); addChild(bg); } } /////////////////////////////////////////////////////////////////////////// |
If you were to run the game now, it would look something like this:
Ok, next up lets create a couple background images. Here’s an example of what we’re going to use:
Again, we’ll use Sprite2d to render the images, but this time we’ll [Embed] the bitmap’s rather than draw them ourselves:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | /////////////////////////////////////////////////////////////////////////// public class Background extends Node2D { //Embed Bg 1 [Embed(source="assets/bg1.png")] private var BgBitmap1:Class; //Embed Bg 2 [Embed(source="assets/bg2.png")] private var BgBitmap2:Class; protected var bg1:Sprite2D; protected var bg2:Sprite2D; public function Background(width:int, height:int){ gameWidth = width; gameHeight = height; } public function init():void { //... Draw Gradient //Create scrolling bg 1 //Create new Sprite2D with the embedded BitmapData var texture2d:Texture2D = Texture2D.textureFromBitmapData(new BgBitmap1().bitmapData); bg1 = new Sprite2D(texture2d); //Set texture options texture2d.textureOptions = TextureOption.FILTERING_NEAREST | //Don't smooth image when scaling (Nearest Neighbor) TextureOption.MIPMAP_DISABLE | //Don't generate MipMaps TextureOption.REPEAT_NORMAL; // Repeat the texture normally //Offset pivot point to 0,0, to mimic displayList bg1.pivot = new Point(-bg1.width>>1,-bg1.height>>1); //Size and position image bg1.height = gameHeight * .7; bg1.scaleX = bg1.scaleY; bg1.y = gameHeight - bg1.height; addChild(bg1); } /////////////////////////////////////////////////////////////////////////// |
Here we’ve create a Sprite2d for one of our background images, and stretched it to fill the Game’s height. You’ll notice that we once again had to set the Pivot point manually, and we also set TextureOptions. The TextureOptions effect image filtering (smoothing), mip-mapping, and how the image repeats.
In this case, we want to use FILTERING_NEAREST to preserve the hard pixel edges, this is roughly equivalent to Bitmap.smoothing=false.
Now we can create the second background layer, this time in condensed form:
0 1 2 3 4 5 6 7 8 | /////////////////////////////////////////////////////////////////////////// bg2 = new Sprite2D(Texture2D.textureFromBitmapData(new BgBitmap2().bitmapData)); bg2.texture.textureOptions = TextureOption.FILTERING_NEAREST | TextureOption.MIPMAP_DISABLE | TextureOption.REPEAT_NORMAL; bg2.pivot = new Point(-bg2.width>>1,-bg2.height>>1); bg2.height = gameHeight * .7; bg2.scaleX = bg2.scaleY; bg2.y = gameHeight - bg2.height; addChild(bg2) /////////////////////////////////////////////////////////////////////////// |
If you run it now, you’ll see the background images, like so:
Looks good you might say….but nothing’s moving yet?! Well this is the easy part…
First some info: A GPU Texture has something called uvOffsetX and uvOffsetY. These are values between 0 and 1, and change how the texture is drawn to the Sprite. For example, if uvOffsetX is .5, the texture will offset by 1/2 it’s width. Think of them as a draw Matrix for the gpu.
So, in order to get a horizontal scrolling effect, we just increment the uvOffsetX of each texture by a very small amount, like .00005. The beautiful thing here, is that textures automatically wrap. So, there’s no need for any additional logic, just keep incrementing, and the backgrounds keep looping.
In order to do apply this offset each frame, we need some sort of hook. Normally you’d use ENTER_FRAME, but with ND2D we override step(). Every ND2D child has a step function which is called each frame. You can override this as you need, and do any per-frame actions you need.
0 1 2 3 4 5 6 7 8 9 10 11 12 | /////////////////////////////////////////////////////////////////////////// public class Background extends Node2D { ..... override protected function step(elapsed:Number):void{ super.step(elapsed); bg1.material.uvOffsetX += .00005 * .5; bg2.material.uvOffsetX += .00005; } } /////////////////////////////////////////////////////////////////////////// |
That’s all we need to do, and we have a nice Parallax effect going on. The backgrounds will scroll forever, and the back layer moves at 1/2 the speed of the other, creating an illusion of depth.
Next up, ground!
Foreground
The foreground in an Endless Runner is essentially a bunch of tiles, laid together in a random style. To manage this, we’ll create Foreground.as. This class will manage the creation, and placement of new tiles.
In order to optimize rendering, we’ll use a new class in ND2D called a Sprite2DBatch. A Sprite2DBatch is used when you need to draw many objects of the same type. By ‘batching’ the draw calls, we can significantly lower the load on the GPU.
Note that you must create a separate batch for each type of texture you have, in this case, we have just 2 textures, groundEdge.png and groundTop.png.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | /////////////////////////////////////////////////////////////////////////// public class Foreground extends Node2D { [Embed(source="assets/groundTop.png")] private var GroundTop:Class; [Embed(source="assets/groundEdge.png")] private var GroundEdge:Class; protected var gameHeight:int; protected var gameWidth:int; protected var spriteList:Vector.<Sprite2D>; protected var groundTopBatch:Sprite2DBatch; protected var topTex:Texture2D; protected var groundEdgeBatch:Sprite2DBatch; protected var edgeTex:Texture2D; public function Foreground(width:int, height:int, physics:PhysicsManager){ this.physics = physics; gameWidth = width; gameHeight = height; spriteList = new <Sprite2D>[]; } public function init():void { //Create a batch for the Top Textures topTex = Texture2D.textureFromBitmapData(new GroundTop().bitmapData); groundTopBatch = new Sprite2DBatch(topTex); addChild(groundTopBatch); //Create a batch for the Edge Textures edgeTex = Texture2D.textureFromBitmapData(new GroundEdge().bitmapData); groundEdgeBatch = new Sprite2DBatch(edgeTex); addChild(groundEdgeBatch); //Add an 8-piece section to start addGroundPieces(8); groundY = spriteList[0].y; } protected function addGroundPieces(length:int, startX:int = 0, height:int = 0):int { var top:GameEntity, edge:GameEntity, x:int; x = startX; //Left edge edge = new Sprite2D(edgeTex); groundEdgeBatch.addChild(edge); edge.pivot = new Point(-edge.width>>1, -edge.height>>1); edge.scaleX = -1; //Flip front edge horizontally edge.x = x + edge.width; //Move edge to account for horizontal flip edge.y = gameHeight - edge.height - height; spriteList.push(edge); x += edge.width; //Middle Pieces for(var i:int = 0; i < length; i++){ top = new Sprite2D(topTex); groundTopBatch.addChild(top); top.pivot = new Point(-top.width>>1, -top.height>>1); top.x = x; top.y = edge.y; x += top.width; spriteList.push(top); } //Right edge edge = new Sprite2D(edgeTex); groundEdgeBatch.addChild(edge); edge.pivot = new Point(-edge.width>>1, -edge.height>>1); edge.x = x; edge.y = gameHeight - edge.height - height; spriteList.push(edge); return x + edge.width; } } /////////////////////////////////////////////////////////////////////////// |
We’ve create 2 Sprite2DBatch’s to hold our textures, and we’ve added an 8-piece stretch of land to start us off.
The addGroundPieces() looks like alot of code, but it just creates a section of land like so:
[--leftEdge--][--middlePiece(s)--][--rightEdge--]
Next we’ll need to move everything each frame, and add new pieces when necessary. We can’t just use UVOffsetX like we did last time, since we’re dealing with a bunch of individual titles. Instead we need to move each one individually.
Again, we’ll override step():
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | /////////////////////////////////////////////////////////////////////////// public class Foreground extends Node2D { ...... override protected function step(elapsed:Number):void { super.step(elapsed); var sprite:GameEntity; //Loop through all sprites and move them to the left for(var i:int = spriteList.length - 1; i >= 0; i--){ sprite = spriteList[i] as Sprite2D; sprite.x -= calculatedSpeed; //If sprite is off screen, dispose of it if(sprite.x < -sprite.width){ spriteList.splice(i, 1); sprite.parent.removeChild(sprite); } } //If the last piece is visible, create more pieces! if(spriteList[spriteList.length-1].x < gameWidth){ //Randomly assign a gap and numPieces value var gap:int = (gameWidth * .1) + (Math.random() * gameWidth * .35); var numTiles:int = 1 + Math.random() * 5; addGroundPieces(numTiles, gameWidth + gap); } } } /////////////////////////////////////////////////////////////////////////// |
We now have am endlessly scrolling foreground!
In addition to moving all the tiles, you can see that the step function also contains the logic for deciding how the next section will be created.
All that’s left now, is to add it to our main Game class:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /////////////////////////////////////////////////////////////////////////// public class Game extends Scene2D { public static var stage:Stage; public static var gameWidth:int; public static var gameHeight:int; public var bg:Background; public var fg:Background; ..... public function init():void { bg = new Background(gameWidth, gameHeight); bg.init(); addChild(bg); fg = new Foreground(gameWidth, gameHeight); fg.init(); addChild(fg); } } /////////////////////////////////////////////////////////////////////////// |
If you publish now, you’ll see something like this:
Player
Ok, so, we have foreground and background, all that’s left is the Player themselves. The player should obviously be some sort of animated character, so this brings us to the need for some Animation.
In order to play animations in ND2D, you need to create a SpriteSheet. To create a SpriteSheet, you need a TextureAtlas. To get a TextureAtlas, you have a couple of options:
- Use a tool like “TexturePacker” to convert your swf’s to a TextureAtlas.
- Use the “ND2D Dynamic Atlas”, which allows you to build a DynamicTextureAtlas from MovieClip’s
Using TexturePacker is a pretty sweet workflow, but the full version of the App costs money. Also, I can’t seem to figure out a good way to make it respect frame labels.
So, I decided to use the DynamicAtlas, as it gives you support for Frame Labels, and is just a faster workflow than using TexturePacker. It’s also totally free! The one downside is that it’s not an officially supported package, and may break when new versions of ND2D are released.
Here you can see how to create an embedded MovieClip, and use it to generate an animated SpriteSheet:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | /////////////////////////////////////////////////////////////////////////// public class PlayerManager { protected var player:Sprite2D; ... public function init(stage:Stage):void { this.stage = stage; //Use ND2D Dynamic Atlas to create a TextureAtlas from a MovieClip //(http://svn.bojinx.com/nd2d-dynamic-atlas/) var runner:MovieClip = new swc.Runner(); var atlas:DynamicAtlas = new DynamicAtlas(); atlas.fromMovieClips([runner], 1, 1); //Create our spriteSheet var spriteSheet:MovieClipAnimation = new MovieClipAnimation(atlas, 24, false, true); //The dynamic atlas requires that we use a Batch to render. //Create batch, and use the atlas.newTexture() API to give us the texture var texture:Texture2D = atlas.newTexture(); texture.textureOptions = TextureOption.FILTERING_NEAREST | TextureOption.MIPMAP_NEAREST | TextureOption.REPEAT_NORMAL; var batch:Sprite2DBatch = new Sprite2DBatch(texture); batch.setSpriteSheet(spriteSheet); //Add batch to foreground so it will be rendered. foreground.addChild(batch); //Create player and add to batch player = new Sprite2D(); batch.addChild(player); //Size and position player initially. player.height = gameHeight * .25 | 0; player.scaleX = player.scaleY; player.x = player.width * 2; player.y = foreground.groundY - player.height; //Play run animation! player.spriteSheet.playAnimation("run"); } } /////////////////////////////////////////////////////////////////////////// |
You’ll notice that I used a Sprite2DBatch to render here. I’m not sure why this is necessary, but the DynamicTextureAtlas requires it. It seems like Sprite2D should be able to accept the Spritesheet directly, but I wasn’t able to get it working. Ah well…it’s an easy enough workaround.
With that we now have a little running dude on stage! We can control him like a regular movieClip, playing “run”, “falling” or “jump” animations.
He looks a little something like this:
Physics
The last thing we need to really complete this core game, is some sort of Physics system. The player needs to be able to jump, when he walks off a cliff he should fall realistically, and when he collides with a solid structure he should stop.
I wasn’t too sure how to go about this, but it seemed simple enough so I decided to hand roll a simple Physics System. It’s a bit outside the scope of this article, but to boil it down:
- All game entities must be registered, with the PhysicsManager, ie: physicsManager.registerEntity(entity:Sprite2D, isFixed:Boolean);
- All entities are either Fixed or Dynamic
- Dynamic items fall from gravity
- Dynamic items can’t enter fixed items
The physics system just goes through all dynamic entities, and applies gravity. It then checks for collisions between Fixed and Dynamic entities, and fixes any that have occurred. Here’s the core bit of logic within PhysicsManager:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | ///////////////////////////////////////////////////////////////////////////// public class PhysicsManager { public var fixedEntities:Array; public var dynamicEntities:Array; public var gravity:Number = 1; public function PhysicsManager() { ... } public function addEntity(entity:GameEntity, isFixed:Boolean = false):void { ... } public function removeEntity(entity:GameEntity, isFixed:Boolean = false):void { ... } public function step():void { var entity:GameEntity; var fixed:GameEntity; var overlap:Point = new Point(); for(var i:int = 0, l:int = dynamicEntities.length; i < l; i++){ entity = dynamicEntities[i] as GameEntity; //Apply VelocityX entity.x += entity.vx; //Apply VelocityY (+ Gravity) entity.vy += gravity; entity.y += entity.vy; //Check for collisions between Dynamic:Fixed Objects for(var j:int = 0, m:int = fixedEntities.length; j < m; j++){ fixed = fixedEntities[j] as GameEntity; //Basic Physics: Check Bounds for Hit //If we have a hit, fix the most shallow direction and continue... if(hitTest(entity, fixed)){ //For now we can cheat by only testing in one direction :) overlap.x = (entity.x + entity.width) - fixed.x; overlap.y = (entity.y + entity.height) - fixed.y; if(overlap.y < overlap.x){ entity.y -= overlap.y; entity.vy = 0; } else { entity.x -= overlap.x; entity.vx = 0; } } } } } } /////////////////////////////////////////////////////////////////////////// |
Because of the nature of the game, we were able to cheat a bit, and only test collisons from the Top and Left direction. We will probably want to fix this at a later date, but it will work fine for now. Also, in a future version, we will probably may want to add support for dynamic:dynamic collisions.
The final step was to go into PlayerManager and Foreground, and make sure that each Sprite2D is registered with the Physics system. We will create a new PhysicsManager() within Game.as and inject that into the classes which require it. We’ll also need to override step() in order to update the PhysicsManager each frame.
Our modified Game class now looks something like this:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | public class Game extends Scene2D { public static var stage:Stage; public static var gameWidth:int; public static var gameHeight:int; public var physics:PhysicsManager; public var player:PlayerManager; public var bg:Background; public var fg:Foreground; public function Game(stage:Stage){ Game.stage = stage; gameWidth = stage.stageWidth; gameHeight = stage.stageHeight; init(); } public function init():void { //Create physics manager physics = new PhysicsManager(); bg = new Background(gameWidth, gameHeight); bg.init(); addChild(bg); //Give physics to fg so it can register it's entities fg = new Foreground(gameWidth, gameHeight, physics); fg.init(); addChild(fg); //Pass the entire game to PlayerManager, it will extract physics from there player = new PlayerManager(gameWidth, gameHeight, this); player.init(Game.stage); } override protected function step(elapsed:Number):void { super.step(elapsed); //Update playerManager player.step(); //Update Phsyics system physics.step(); } } |
Once the physics system was complete, and all entities are registered, making the character jump was dead simple. Just apply some initial velocity, and gravity will take care of the rest! I also added a small bit of logic to control Player Animations, and allow to triple-jump:
0 1 2 3 4 5 6 7 8 9 10 | /////////////////////////////////////////////////////////////////////////// public class PlayerManager { .... protected function onMouseDown(event:Event):void { if(jumpCount++ > 2){ return; } //Allow triple jump! player.vy = -jumpForce; //To jump, just add some -velocity to the player setState(STATE_JUMPING);//Show jump animation } } /////////////////////////////////////////////////////////////////////////// |
With that, we have the core for a fully functional Endless Runner! I hope you enjoyed the read!
Click the thumbnail below to play:
Up Next…
The game is starting to get there, but there are a few more loose ends to get to. In part 2 of this tutorial, we’ll look at bringing this to the next level:
- Fix the timestep! Game should run as well at 30fps as it does at 60…
- Pool all Sprite’s
- Add Enemies
- Add Score / HUD
If you made it all the way down here, thanks for reading
Nice article! It’s always nice to have tutorials on frameworks for new technologies.
I would like to point out some errata in order to improve the quality of your already awesome article
1. Foreground class code, line 18. Unnecessary space after var.
2. Foreground class code, line 34 comment refers to Top textures instead of Edge textures.
3. Foreground class code, line 76 should be the same as 55 (height is missing in the operation)
4. Foreground class code, line 73 has an unnecessary empty line. I recommend removing it to make this section, //Right edge, look the same as the one before, //Left edge
5. There is a “it’s it’s not an official…” repetition in the -Player- section.
6. PlayerManager class code, line 22 has a ; missing at the end on the line.
7. PlayerManager class code, line 26 has the foreground.addChild(batch) commented out.
8. PhysicsManager class code, line 16, does removeEntity() function really need to have the isFixed parameter?
9. PhysicsManager class code, lines 25 and 36 could use a ++i and ++j respectively.
10. PlayerManager onMouseDown code, line 6 comment typo in jump (reads jumo).
That’s all I could find. Again, thanks for the article!
Thanks, you rock! Most of that code is pseudo-code anyways, but I will fix
It looks great! But when we click the thumbnail it doesn’t play
Woops, I went though and resized all the thumbs, and forgot to re-insert the link. Thanks!
Aahhh soo helpful!
Would absolutely love to see more information like this
This saved me a ton of time. I’m so used to C… I’d have never figured all these things out on my own.
Great info, thanks for sharing! The pivot point and texture option hints really helped a lot.