Flame是一款基于Flutter的2D游戲引擎,今天我將使用它制作一款經(jīng)典小游戲割繩子
基本物品準(zhǔn)備
添加游戲背景
游戲的背景圖片資源包含多個(gè)圖片,這里通過Sprite
的截取功能裁出來(lái)我們需要的部分坏怪,然后添加到游戲中作為背景
final bgSprite = await Sprite.load("bgr_01_hd.jpeg",
images: images,
srcSize: Vector2(770, 1036),
srcPosition: Vector2(0, 0));
final bgComponent = SpriteComponent(sprite: bgSprite)
..anchor = Anchor.center
..position = Vector2(size.x * 0.5, size.y * 0.5);
add(bgComponent);
小怪獸
小怪獸的Sprite Sheet圖如下简烘,想要使用它構(gòu)建動(dòng)畫笑陈,需要知道每一個(gè)Sprite的大小以及偏移的位置放刨,這些數(shù)據(jù)需要一些手段獲取,我硬編碼在了項(xiàng)目中云稚。
新建CTRPlayer
類表示小怪獸隧饼,他繼承自PositionComponent
class CTRPlayer extends PositionComponent {
...
}
通過Sprite大小和偏移數(shù)據(jù)構(gòu)建小怪獸的Sprite
列表,使用ImageComposition
整合出符合統(tǒng)一規(guī)范的Sprite
Future<List<Sprite>> _genSpriteSlices() async {
List<Sprite> sprites = [];
final List<Map<String, double>> rects = [
...
];
final List<Offset> offsets = spriteOffsets();
for (int i = 0; i < rects.length; ++i) {
final rect = rects[i];
final pos = Vector2(rect["x"]!, rect["y"]!);
final size = Vector2(rect["M"]!, rect["U"]!);
final sprite = await Sprite.load("char_animations.png",
srcPosition: pos, srcSize: size, images: images);
final offset = offsets[i];
final composition = ImageComposition()
..add(await sprite.toImage(), Vector2(offset.dx, offset.dy));
final composeImage = await composition.compose();
sprites.add(Sprite(composeImage));
}
return sprites;
}
List<Offset> spriteOffsets() {
final List<Map<String, double>> raw = [
...
];
List<Offset> offsets = [];
for (final pos in raw) {
offsets.add(Offset(pos["x"]! - 76, pos["y"]! - 83));
}
return offsets;
}
通過SpriteAnimationGroupComponent
管理小怪獸的多個(gè)動(dòng)畫静陈,SpriteAnimationGroupComponent
可以將多組幀動(dòng)畫合并燕雁,并通過將current
設(shè)置為對(duì)應(yīng)狀態(tài)值快速切換動(dòng)畫
final animMap = {
CTRPlayerAnimState.reset: SpriteAnimation.spriteList(
_sprites.sublist(0, 1),
stepTime: 0.06,
loop: true),
CTRPlayerAnimState.eat: SpriteAnimation.spriteList(
_sprites.sublist(27, 40),
stepTime: 0.06,
loop: false),
CTRPlayerAnimState.idle1: SpriteAnimation.spriteList(
_sprites.sublist(64, 83),
stepTime: 0.06,
loop: false),
CTRPlayerAnimState.idle2: SpriteAnimation.spriteList(
_sprites.sublist(53, 61),
stepTime: 0.06,
loop: false),
};
_animationComponent = SpriteAnimationGroupComponent(
animations: animMap, current: CTRPlayerAnimState.reset);
接著我們讓小怪獸在沒吃到糖果前隨機(jī)進(jìn)行idle1
和idle2
兩種動(dòng)畫,通過_lastIdleAnimElapsedTime
控制每8s嘗試播放一次idle1
或者idle2
動(dòng)畫
@override
update(double dt) {
super.update(dt);
_lastIdleAnimElapsedTime += dt;
if (_lastIdleAnimElapsedTime > 8 &&
_animationComponent.current == CTRPlayerAnimState.reset &&
_animationComponent.current != CTRPlayerAnimState.eat) {
_lastIdleAnimElapsedTime = 0;
final states = [CTRPlayerAnimState.idle1, CTRPlayerAnimState.idle2];
final state = states[Random().nextInt(states.length)];
_animationComponent.current = state;
}
}
最后給小怪獸增加一個(gè) duan~ duan~ duan~ 的效果窿给,通過ScaleEffect
反復(fù)縮放實(shí)現(xiàn)
final charEffect = ScaleEffect.to(
Vector2(1.1, 0.9),
EffectController(
duration: 0.6,
reverseDuration: 0.3,
infinite: true,
curve: Curves.easeOutCubic));
_animationComponent.add(charEffect);
糖果
新建CTRCandy
類表示糖果贵白,糖果只是簡(jiǎn)單的合并了前景圖和背景圖,原始素材上有閃光幀動(dòng)畫崩泡,想要添加的話禁荒,再增加一層SpriteAnimationComponent
展示即可
final candyBg = await Sprite.load("obj_candy_01.png",
images: images, srcPosition: Vector2(2, 2), srcSize: Vector2(87, 90));
_candyBgComponent = SpriteComponent(sprite: candyBg)
..anchor = Anchor.center
..position = Vector2(6, 13);
add(_candyBgComponent);
final candyFg = await Sprite.load("obj_candy_01.png",
images: images, srcPosition: Vector2(2, 95), srcSize: Vector2(60, 60));
_candyFgComponent = SpriteComponent(sprite: candyFg)
..anchor = Anchor.center
..position = Vector2(0, 0);
add(_candyFgComponent);
固定點(diǎn)
固定點(diǎn)和糖果類似,也只是簡(jiǎn)單的合并了前景圖和背景圖角撞,新建了CTRHook
類表示
final hookBg = await Sprite.load("obj_hook_01.png",
images: images, srcPosition: Vector2(2, 2), srcSize: Vector2(50, 50));
final hookBgComponent = SpriteComponent(sprite: hookBg)
..anchor = Anchor.topLeft
..position = Vector2(0, 0);
add(hookBgComponent);
final hookFg = await Sprite.load("obj_hook_01.png",
images: images, srcPosition: Vector2(2, 55), srcSize: Vector2(15, 14));
final hookFgComponent = SpriteComponent(sprite: hookFg)
..anchor = Anchor.center
..position = Vector2(25, 25);
add(hookFgComponent);
星星
星星包含兩組動(dòng)畫呛伴,旋轉(zhuǎn)和消失勃痴,新建CTRStar
類表示并通過SpriteAnimationGroupComponent
管理動(dòng)畫
final idleSprites = await _idleSprites();
final disappearSprites = await _disappearSprites();
final animMap = {
CTRStarState.idle: SpriteAnimation.spriteList(idleSprites.sublist(1, 18),
stepTime: 0.05, loop: true),
CTRStarState.disappear: SpriteAnimation.spriteList(
disappearSprites.sublist(0, 12),
stepTime: 0.05,
loop: false),
};
_animationComponent = SpriteAnimationGroupComponent(
animations: animMap, current: CTRStarState.idle);
_animationComponent.position = Vector2(0, 0);
_animationComponent.anchor = Anchor.topLeft;
add(_animationComponent);
繩子模擬
創(chuàng)建CTRRope
類表示繩子
物理模擬
繩子的模擬采用了多個(gè)BodyComponent
使用RopeJoint
鏈接的方式實(shí)現(xiàn),首先創(chuàng)建CTRRopeSegment
表示繩子的一段热康,它繼承自BodyComponent
沛申,主要支持物理模擬,不參與渲染
class CTRRopeSegment extends BodyComponent {
final Offset pos;
late Vector2 _size;
bool isBreak = false;
CTRRopeSegment({this.pos = const Offset(0, 0)}) {
_size = Vector2(10, 10);
renderBody = false;
}
@override
Body createBody() {
final bodyDef = BodyDef(
type: BodyType.dynamic,
userData: this,
position: Vector2(pos.dx, pos.dy));
return world.createBody(bodyDef)
..createFixtureFromShape(CircleShape()..radius = _size.x * 0.5,
density: 1, friction: 0, restitution: 0);
}
}
接著還需要?jiǎng)?chuàng)建另一個(gè)類CTRRopePin
姐军,它和CTRRopeSegment
類似铁材,但是他不能動(dòng),用與將繩子的一頭固定
class CTRRopePin extends BodyComponent {
double size;
Offset pos;
CTRRopePin({this.size = 20, this.pos = const Offset(0, 20)}) {
renderBody = false;
}
@override
Body createBody() {
final bodyDef = BodyDef(
type: BodyType.static,
userData: this,
position: Vector2(this.pos.dx, this.pos.dy));
return world.createBody(bodyDef)
..createFixtureFromShape(CircleShape()..radius = size * 0.5,
friction: 0.2, restitution: 0.5);
}
}
萬(wàn)事俱備奕锌,將它們湊成一根繩子
final pin = CTRRopePin(pos: Offset(startPosition.dx, startPosition.dy));
add(pin);
await Future.wait([pin.loaded]);
const ropeSegLen = 8.0;
final ropeSegCount = length ~/ ropeSegLen;
final deltaOffset = (endPosition - startPosition);
final space = deltaOffset.distance / ropeSegCount;
final dirVec = deltaOffset / deltaOffset.distance;
CTRRopeSegment? lastRopeSeg;
for (int i = 0; i < ropeSegCount; ++i) {
final seg =
CTRRopeSegment(pos: dirVec * i.toDouble() * space + startPosition);
add(seg);
await Future.wait([seg.loaded]);
final jointDef = RopeJointDef()
..bodyA = lastRopeSeg?.body ?? pin.body
..bodyB = seg.body
..maxLength = ropeSegLen;
game.world.createJoint(RopeJoint(jointDef));
lastRopeSeg = seg;
_ropSegs.add(seg);
}
首先創(chuàng)建CTRRopePin
作為繩子的開端著觉,然后通過各種參數(shù)計(jì)算出需要多少個(gè)CTRRopeSegment
,最后通過RopeJoint
逐個(gè)相連惊暴。打開CTRRopeSegment
的renderBody
饼丘,可以大致看出繩子的模擬效果
繩子渲染
游戲中繩子是兩種顏色相間的,我們可以在CTRRope
的void render(Canvas canvas)
中進(jìn)行自定義繪制辽话,首先準(zhǔn)備好要繪制的點(diǎn)和兩種顏色的Paint
List<Offset> points = [];
points.add(startPosition);
final paint1 = Paint()
..color = const Color(0xff815c3c)
..style = PaintingStyle.stroke
..strokeWidth = 5
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round;
final paint2 = Paint()
..color = const Color.fromARGB(255, 65, 44, 27)
..style = PaintingStyle.stroke
..strokeWidth = 5
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round;
for (int i = 0; i < _ropSegs.length; i++) {
final currenPt = _ropSegs[i].position;
points.add(Offset(currenPt.x, currenPt.y));
}
接著每隔4段更換一次顏色肄鸽,并通過drawLine
繪制繩子
final newPoints = points;
bool togglePaint = false;
for (int i = 0; i < newPoints.length - 1; i++) {
if (i % 4 == 0) {
togglePaint = !togglePaint;
}
final paint = togglePaint ? paint1 : paint2;
canvas.drawLine(Offset(newPoints[i].dx, newPoints[i].dy),
Offset(newPoints[i + 1].dx, newPoints[i + 1].dy), paint);
}
如何切斷繩子
切斷繩子需要解決2個(gè)問題,一個(gè)是如何判斷哪一段被切到油啤,還有就是切完之后渲染不正確的問題典徘。
如何判斷哪一段被切到
我采用了一個(gè)簡(jiǎn)單的方案,創(chuàng)建一個(gè)繼承自BodyComponent
的類CTRScissors
村砂,手指移動(dòng)時(shí)控制它的位置烂斋,如果它和CTRRopeSegment
發(fā)生的碰撞,則從被碰撞的CTRRopeSegment
處切斷繩子
class CTRScissors extends BodyComponent with ContactCallbacks {
bool valid = true;
CTRScissors() {
renderBody = false;
}
updatePosition(Vector2 newPos) {
body.setTransform(newPos, 0);
}
@override
Body createBody() {
const bodySize = 20.0;
final bodyDef = BodyDef(
type: BodyType.dynamic,
gravityOverride: Vector2(0, 0),
userData: this,
bullet: true,
position: Vector2(0, 0));
return world.createBody(bodyDef)
..createFixtureFromShape(CircleShape()..radius = bodySize * 0.5,
density: 1, friction: 0.2, restitution: 0.5);
}
@override
void beginContact(Object other, Contact contact) {
super.beginContact(other, contact);
if (other is CTRRopeSegment) {
if (valid && !other.isBreak) {
other.removeFromParent();
other.isBreak = true;
}
}
}
}
other.removeFromParent();
會(huì)直接讓繩子變成2段础废,other.isBreak = true;
則是用于防止多次移除和解決切斷后渲染問題
切完之后如何渲染
只需要做一些小改動(dòng),首先如果CTRRopeSegment
的isBreak
為true
罕模,添加一個(gè)-1,-1
點(diǎn)到points
中
for (int i = 0; i < _ropSegs.length; i++) {
final currenPt = _ropSegs[i].position;
if (_ropSegs[i].isBreak) {
points.add(const Offset(-1, -1));
} else {
points.add(Offset(currenPt.x, currenPt.y));
}
}
接著繪制時(shí)發(fā)現(xiàn)當(dāng)前點(diǎn)或者接下來(lái)一個(gè)點(diǎn)坐標(biāo)都小于0评腺,直接不繪制
for (int i = 0; i < newPoints.length - 1; i++) {
if (i % 4 == 0) {
togglePaint = !togglePaint;
}
final paint = togglePaint ? paint1 : paint2;
if ((newPoints[i + 1].dx < 0 && newPoints[i + 1].dy < 0) ||
(newPoints[i].dx < 0 && newPoints[i].dy < 0)) {
continue;
}
canvas.drawLine(Offset(newPoints[i].dx, newPoints[i].dy),
Offset(newPoints[i + 1].dx, newPoints[i + 1].dy), paint);
}
將糖果掛到繩子上
糖果想要掛載到繩子上,首先自己需要繼承BodyComponent
淑掌,然后將自身傳遞給CTRRope
蒿讥,CTRRope
增加attachComponent
用于接收掛載物
final Offset startPosition;
final BodyComponent? attachComponent;
final double length;
final List<CTRRopeSegment> _ropSegs = [];
CTRRope(
{this.startPosition = const Offset(0, 0),
this.length = 100,
this.attachComponent});
CTRRope
發(fā)現(xiàn)掛載物不為空時(shí),創(chuàng)建RopeJoint
將繩子最后一段和掛載物連接起來(lái)
if (attachComponent != null) {
final jointDef = RopeJointDef()
..bodyA = lastRopeSeg?.body ?? pin.body
..bodyB = attachComponent!.body
..maxLength = ropeSegLen;
game.world.createJoint(RopeJoint(jointDef));
}
采集星星
糖果和星星的碰撞檢測(cè)抛腕,我使用了flame的CollisionCallbacks
芋绸,但是我發(fā)現(xiàn)無(wú)法直接給繼承自BodyComponent
的CTRCandy
開啟CollisionCallbacks
,只能新建一個(gè)專門用于碰撞檢測(cè)的組件
class CTRCandyCollisionComponent extends PositionComponent
with CollisionCallbacks {
final WeakReference<CTRCandy>? candy;
CTRCandyCollisionComponent({this.candy});
@override
FutureOr<void> onLoad() {
size = Vector2(40, 40);
add(CircleHitbox(radius: 30)
..anchor = Anchor.center
..position = Vector2(0, 0));
return super.onLoad();
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is CTRStar) {
other.disappear();
}
}
}
但是這個(gè)組件如果直接add
到CTRCandy
上担敌,CollisionCallbacks
依然無(wú)法生效摔敛,經(jīng)過嘗試,我將它掛載到了根節(jié)點(diǎn)上全封,并通過update
同步CTRCandy
和CTRCandyCollisionComponent
的位置
class CTRCandy extends BodyComponent {
...
@override
Future<void> onLoad() async {
...
collisionComponent = CTRCandyCollisionComponent(candy: WeakReference(this));
parent?.add(collisionComponent);
...
}
@override
void update(double dt) {
super.update(dt);
collisionComponent.position = body.position;
}
}
CTRStar
繼承自PositionComponent
马昙,所以直接加一個(gè)CircleHitbox
即可
add(CircleHitbox(radius: 10)
..anchor = Anchor.center
..position = size * 0.5);
碰撞時(shí)桃犬,調(diào)用CTRStar
的disappear()
觸發(fā)消失動(dòng)畫,CTRStar
通過對(duì)animationTickers
的監(jiān)控行楞,在動(dòng)畫結(jié)束時(shí)銷毀自己
disappear() {
_animationComponent.current = CTRStarState.disappear;
_animationComponent.animationTickers?[CTRStarState.disappear]?.onComplete =
() {
removeFromParent();
};
}
怪獸吃到糖果
怪獸和糖果之間也是采用的CollisionCallbacks
進(jìn)行檢測(cè)
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is CTRCandyCollisionComponent) {
other.candy?.target?.beEaten();
eat();
}
}
發(fā)現(xiàn)怪獸碰撞到了糖果攒暇,調(diào)用糖果的beEaten
觸發(fā)fadeOut效果,通過OpacityEffect
實(shí)現(xiàn)
beEaten() {
_candyBgComponent.add(OpacityEffect.fadeOut(EffectController(duration: 0.3))
..removeOnFinish = false);
_candyFgComponent.add(OpacityEffect.fadeOut(EffectController(duration: 0.3))
..removeOnFinish = false);
}
eat();
則是觸發(fā)了小怪獸的干飯動(dòng)畫
eat() {
_animationComponent.animationTickers?[CTRPlayerAnimState.eat]?.reset();
_animationComponent.current = CTRPlayerAnimState.eat;
}
合在一起子房,布置一個(gè)關(guān)卡
首先添加小怪獸
_player = CTRPlayer(images: images)
..anchor = Anchor.center
..position = Vector2(size.x * 0.8, size.y * 0.8);
add(_player);
然后布置小星星
add(CTRStar(images: images)
..anchor = Anchor.center
..position = Vector2(100, 400));
add(CTRStar(images: images)
..anchor = Anchor.center
..position = Vector2(220, 430));
add(CTRStar(images: images)
..anchor = Anchor.center
..position = Vector2(320, 530));
接下來(lái)創(chuàng)建糖果但是不添加
final candy = CTRCandy(images: images, pos: Offset(100, 200));
最后布置繩子并添加糖果
{
final hook = CTRHook(images: images)
..anchor = Anchor.center
..position = Vector2(100, 100);
final rope = CTRRope(
startPosition: hook.position.toOffset(),
attachComponent: candy,
length: 200);
add(rope);
add(hook);
}
{
final hook = CTRHook(images: images)
..anchor = Anchor.center
..position = Vector2(250, 100);
final rope = CTRRope(
startPosition: hook.position.toOffset(),
attachComponent: candy,
length: 300);
add(rope);
add(hook);
}
add(candy);
就可以得到開頭的游戲場(chǎng)景啦~
接下來(lái)...
簡(jiǎn)單總結(jié)一下形用,這個(gè)小游戲主要涉及以下技術(shù)點(diǎn)
-
SpriteAnimationComponent
和SpriteAnimationGroupComponent
的使用 -
flame_forge2d
的基礎(chǔ)用法和RopeJoint
的使用 -
flame
碰撞檢測(cè)的使用
訪問 https://github.com/BuildMyGame/FlutterFlameGames 可以獲取完整代碼,更多細(xì)節(jié)閱讀代碼就可以知道了哦~