libGDX physics with Box2D

libGDX physics with Box2D

אחד הנושאים המורכבים ביותר בפיתוח משחקים הוא השימוש במנוע הפיזיקלי. בפוסט הקרוב אנסה להסביר על איך להשתמש בעולם פיזיקלי של Box2D, בסביבת libGDX.

תאוריה

ניצור עולם – מרחב בו אנו מגדירים מהם הכוחות הפועלים. מה יהיה כוח המשיכה בציר ה- Y (אפשר 9.81, אבל לא חייבים), מה כוח המשיכה בציר ה- X (אם רוצים).
לתוך העולם הזה ניצור "גופים". לכל גוף יש הגדרות כמו לגוף בעולם האמיתי: צורה, מסה, מהירות וכו'.

ישנם שלושה סוגי גופים:

  • דינמי – גוף שפועלים עליו כל הכוחות.
  • סטטי – גוף שלא פועלים עליו כוחות – גם לא כוח המשיכה, כך שאי אפשר להזיז אותו.
  • קינמטי – גוף שלא פועלים עליו כוחות, אבל אפשר להזיז.

כל אחד מהסוגים הנ"ל ממלא תפקיד במשחק. לדוגמא, במשחק כמו 'סופר מריו' השחקן יהיה גוף דינמי, הפלטפורמות עליהן הוא קופץ יהיו סטטיות, ופלטפורמות שזזות יהיו קינמטיות.

ועכשיו למעשה.

קוד

ניצור פרוייקט חדש בשם Box2dExample, ונבחר ב- extensions את הספרייה Box2D.

נלך למחלקה ב- Core שנקראת Box2dExample, ונוסיף את המשתנים הבאים:

private World world;
	private Box2DDebugRenderer b2dr;
	private OrthographicCamera b2dCam;
	private int SCREEN_WIDTH = 480;
	private int SCREEN_HEIGHT = 800;

המחלקה שאיתה יוצרים עולם ב- Box2d נקראת (באופן מפתיע) World. כדי לדבג אותה נשתמש באובייקט מסוג Box2DDebugRenderer, שיודע לרנדר את כל הגופים שקיימים כרגע בעולם.

נוסיף לפונקציית create את הקוד הבא:

public void create () {
		world = new World(new Vector2(0, 9.81f), false);
		b2dCam = new OrthographicCamera();
		b2dCam.setToOrtho(true, SCREEN_WIDTH,
				SCREEN_HEIGHT);
		b2dr = new Box2DDebugRenderer();
 
		createBall(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2);
		createGround(new Rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.75f, 100, 20));
	}

כדי ליצור עולם דרושים בסך הכל שני פרמטרים: וקטור של כוח המשיכה הפועל בעולם (במקרה שלנו זה כמו בעולם האמיתי – 0 בציר ה- x ו- 9.81 מטר לשניה בציר ה- y). ומשתנה בוליאני שקובע האם לפעול גם על גופים שאינם פעילים (לשיפור ביצועים עדיף false).

זה היה קל. עכשיו נעבור לגופים.

נתחיל בגוף סטטי:

private void createGround(Rectangle rect){
		BodyDef bdef = new BodyDef();
 
		bdef.position.set(rect.getX(), rect.getY());
		bdef.type = BodyDef.BodyType.StaticBody;
		Body body = world.createBody(bdef);
 
		PolygonShape shape = new PolygonShape();
		shape.setAsBox(rect.getWidth(), rect.getHeight());
		FixtureDef fdef = new FixtureDef();
		fdef.friction = 0.5f;
		fdef.shape = shape;
 
		body.createFixture(fdef).setUserData("ground");
 
		body.setUserData(this);
 
 
		shape.dispose();
	}

כדי ליצור Body, צריך קודם כל ליצור BodyDef שם מגדירים את סוג הגוף ומיקומו.

אז מגדירים את ה- Shape בו רוצים להשתמש, והגודל שלו (חשוב לזכור לעשות dispose אחרי השימוש בו!).

ואז מגדירים FixtureDef עם כל התכונות שנרצה לגוף. וזהו.

באותה הדרך נגדיר גם גוף דינמי:

private void createBall(int x, int y){
		BodyDef bdef = new BodyDef();
 
		bdef.position.set(x, y);
		bdef.type = BodyDef.BodyType.DynamicBody;
		Body ball = world.createBody(bdef);
 
		CircleShape shape = new CircleShape();
		shape.setRadius(20f);
		FixtureDef fdef = new FixtureDef();
		fdef.restitution = 0.7f;
		fdef.density = 2f;
		fdef.friction = 0.25f;
		fdef.shape = shape;
 
		ball.createFixture(fdef).setUserData("ball");
		ball.setUserData(this);
		shape.dispose();
	}

התכונה "restitution" (פיצוי) קובעת בזמן התנגשות כמה מתוך הכוח יוחזר. כלומר אם הערך יהיה ״1״ הכדור יפול ויעלה לנצח.

נוסיף לפונקציית render את הקוד הבא:

public void render () {
		Gdx.gl.glClearColor(0, 0, 0, 1);
		Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
 
		world.step(Gdx.graphics.getDeltaTime(), 6, 2);
 
		b2dr.render(world, b2dCam.combined);
	}

כדי שה״עולם״ יתעדכן צריך בכל פעם לקרוא ל- step.

אם נריץ עכשיו, זה מה שנקבל:

box2d

האובייקט Box2DDebugRenderer מצייר בצבע שונה כל סוג של Body. ירוק זה גופים סטטיים, וכו׳.

נראה טוב, לא? אבל… זה זז קצת לאט. אפילו מאד לאט.

צריך לקחת בחשבון את שיטת המדידה שבה משתמשים ב- Box2d. כלומר כשקבענו שהכוחות הפועלים הם 9.81 מטר לשניה – לא הגדרנו מה נקרא מטר. ה- default זה שכל פיקסל הוא מטר אחד… כלומר מה שאנחנו רואים כאן זה בעצם כדור בקוטר 40 מטר נופל על משטח בגובה 20 מטר. זה כבר יותר מובן.

אבל עדיין זה לא נוח לעבוד ככה. צריך לקבוע כמה פיקסלים יחשבו למטר אחד, ולשנות לפי זה את התצוגה.

נוסיף משתנה בשם PPM – כלומר Pixels Per Meter:

private float PPM = 100;

ובכל מקום בו יש התייחסות למיקום/גודל על גבי המסך, נחלק ב- PPM, וגם את הגדרת גודל המצלמה בה אנחנו משתמשים b2dCam נחלק ב- PPM.

אחרי השינוי זה יראה ככה:

box2d_fixed

זה כבר יותר הגיוני.

מה עושים עם זה

במשחק אמיתי כמובן לא נשתמש ב- Box2DDebugRenderer בשביל להציג. נשמור את המיקום של כל Body בו אנחנו משתמשים, ונצייר שם צורה/תמונה באותו מיקום (לא לשכוח להכפיל בחזרה ב- PPM!). בשלבי הפיתוח נשתמש בשתי מצלמות – אחת בשביל ה- Box2DDebugRenderer והשנייה לצורות האמיתיות – ואחרי שנראה שזה חופף, ״נכבה״ את הרינדור של הדיבאגר.

הנה דוגמא פשוטה לאפליקציה שמציגה המון כדורים שנופלים:

package com.yair.physics;
 
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.physics.box2d.Body;
import com.badlogic.gdx.physics.box2d.BodyDef;
import com.badlogic.gdx.physics.box2d.Box2DDebugRenderer;
import com.badlogic.gdx.physics.box2d.CircleShape;
import com.badlogic.gdx.physics.box2d.FixtureDef;
import com.badlogic.gdx.physics.box2d.PolygonShape;
import com.badlogic.gdx.physics.box2d.World;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.TimeUtils;
 
public class Box2dExample extends Game {
	private World world;
	// only for debug
//	private Box2DDebugRenderer b2dr;
//	private OrthographicCamera b2dCam
	private OrthographicCamera cam;
	private int SCREEN_WIDTH = 480;
	private int SCREEN_HEIGHT = 800;
	private float PPM = 100;
	private long lastSpawnTime;
 
	private Array<ColorBody> bodies = new Array<ColorBody>();
	private ShapeRenderer shapeRenderer;
 
	@Override
	public void create () {
		world = new World(new Vector2(0, 9.81f), false);
		// only for debug
//		b2dCam = new OrthographicCamera();
//		b2dCam.setToOrtho(true, SCREEN_WIDTH / PPM,
//				SCREEN_HEIGHT / PPM);
 
		cam = new OrthographicCamera();
		cam.setToOrtho(true, SCREEN_WIDTH,
				SCREEN_HEIGHT);
		// only for debug
//		b2dr = new Box2DDebugRenderer();
		shapeRenderer = new ShapeRenderer();
 
		createGround(new Rectangle(SCREEN_WIDTH / 2, SCREEN_HEIGHT, SCREEN_WIDTH, 10));
	}
 
	private void createBall(int x, int y){
		BodyDef bdef = new BodyDef();
		bdef.position.set(x / PPM, y / PPM);
		bdef.type = BodyDef.BodyType.DynamicBody;
		Body ball = world.createBody(bdef);
		CircleShape shape = new CircleShape();
		shape.setRadius(20f / PPM);
		FixtureDef fdef = new FixtureDef();
		fdef.restitution = 0.7f;
		fdef.density = 2f;
		fdef.friction = 0.25f;
		fdef.shape = shape;
		ball.createFixture(fdef).setUserData("ball");
		ball.setUserData(this);
		shape.dispose();
 
		bodies.add(new ColorBody(ball));
	}
 
	private void createGround(Rectangle rect){
		BodyDef bdef = new BodyDef();
		bdef.position.set(rect.getX() / PPM, rect.getY() / PPM);
		bdef.type = BodyDef.BodyType.StaticBody;
		Body ground = world.createBody(bdef);
		PolygonShape shape = new PolygonShape();
		shape.setAsBox(rect.getWidth() / PPM, rect.getHeight() / PPM);
		FixtureDef fdef = new FixtureDef();
		fdef.restitution = 0f;
		fdef.friction = 0.5f;
		fdef.shape = shape;
		ground.createFixture(fdef).setUserData("ground");
		ground.setUserData(this);
		shape.dispose();
	}
	@Override
	public void render () {
		Gdx.gl.glClearColor(0, 0, 0, 1);
		Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
		if ((TimeUtils.nanoTime() - lastSpawnTime)/10000 > 5000) {
			// spawn ball
			createBall( MathUtils.random(50, SCREEN_WIDTH - 50), 0);
			lastSpawnTime = TimeUtils.nanoTime();
		}
		world.step(Gdx.graphics.getDeltaTime(), 6, 2);
		// only for debug
//		b2dr.render(world, b2dCam.combined);
 
		// render ground
		shapeRenderer.setColor(Color.WHITE);
		shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
		shapeRenderer.rect(0, 0, SCREEN_WIDTH, 10);
		shapeRenderer.end();
 
		// iterate through balls array to render them
		for (ColorBody b : bodies) {
			shapeRenderer.setColor(b.color);
			shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
			shapeRenderer.circle(b.body.getPosition().x * PPM, 
SCREEN_HEIGHT - b.body.getPosition().y * PPM, 20);
			shapeRenderer.end();
		}
	}
 
	@Override
	public void dispose () {
		shapeRenderer.dispose();
	}
 
	// we want to save each ball with its specific color, so we use this struct
	private class ColorBody {
		public Body body;
		public Color color;
		public ColorBody(Body body) {
			this.body = body;
			this.color = getRandColor();
		}
		// generate random color from this list
		private Color getRandColor(){
			int rand = MathUtils.random(10);
			Color toRet;
			switch (rand) {
				case 0:
					toRet = Color.LIME;
					break;
				case 1:
					toRet = Color.RED;
					break;
				case 2:
					toRet = Color.YELLOW;
					break;
				case 3:
					toRet = Color.GREEN;
					break;
				case 4:
					toRet = Color.BLUE;
					break;
				case 5:
					toRet = Color.ORANGE;
					break;
				case 6:
					toRet = Color.DARK_GRAY;
					break;
				case 7:
					toRet = Color.MAGENTA;
					break;
				case 8:
					toRet = Color.MAROON;
					break;
				case 9:
					toRet = Color.GRAY;
					break;
				default:
					toRet = Color.WHITE;
					break;
			}
			return  toRet;
		}
	}
}

וזה נראה כך (זאת תמונה ב- Debug Mode, אפשר לראות את קווי המתאר של ה- Bodies, ואיך הם חופפים במדוייק את הציור):

balls

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedIn

כתיבת תגובה

האימייל לא יוצג באתר. שדות החובה מסומנים *