最近用flutter练手写了一个demo,模拟了回合制战斗的整个过程,包括战斗过程中的用户交互逻辑,另外,回合制中的排兵布阵也已经包含在内了
效果展示
开始前的准备
- 定义好战斗者的属性,至少包括名字,攻击值,防御值,速度值,生命值
- 定义好阵型的属性,至少包括名称,槽位列表
定义战场 Battlefield
战场分为我方和敌方,我方位于战场下半区,敌方位于战场上半区
定义战场的大小,即容纳多少人,根据这个值可以设定战场的战斗槽
class Battlefield {
List topSlotList = List();
List bottomSlotList = List();
static const int row = 3;
static const int column = 3;
Battlefield() {
for (int i = 0; i < column; i++) {
for (int j = 0; j < row; j++) {
topSlotList.add(BattleSlot(i, j, false));
bottomSlotList.add(BattleSlot(i, j, true));
}
}
}
}
提供方法为每个战斗槽设定位置
/// Axy
/// A02 A21 A22
/// A01 A11 A21
/// A00 A10 A20
/// -----------
/// B00 B10 B20
/// B01 B11 B21
/// B02 B12 B22
/// Bxy
computeSlotPosition(double fieldWidth, double fieldHeight) {
// +1 是为了留1个槽的大小均分给槽与槽之间的空隙
var slotWidth = fieldWidth / (row + 1);
var slotHeight = fieldHeight / (column + 1);
// +1 是为了在槽阵的外围设置空隙,否则就要 -1
var spaceH = slotWidth / (row + 1);
var spaceV = slotHeight / (column + 1);
var firstRect = Rect.fromLTWH(spaceH, spaceV, slotWidth, slotHeight);
if (row % 2 == 0) firstRect = firstRect.translate(spaceH / 2, 0);
if (column % 2 == 0) firstRect = firstRect.translate(0, spaceV / 2);
for (BattleSlot slot in topSlotList) {
double dy = (slotHeight + spaceV) * (2 - slot.y);
double dx = (slotWidth + spaceH) * slot.x;
slot.relRect = firstRect.translate(dx, dy);
}
for (BattleSlot slot in bottomSlotList) {
double dy = (slotHeight + spaceV) * slot.y;
double dx = (slotWidth + spaceH) * slot.x;
slot.relRect = firstRect.translate(dx, dy);
}
}
提供方法让战斗者绑定到战斗槽上,根据阵型的槽位顺序,依次绑定到指定槽位
void setupIntruder(ArrayShape arrayType, List intruderArray) {
var bottomArray = intruderArray.map((e) => RunningFighter(e)).toList();
for (int i = 0; i < bottomArray.length; i++) {
var intruder = bottomArray[i];
var pair = arrayType.arraySlot[i];
bottomSlotList.firstWhere((e) => e.x == pair.x && e.y == pair.y).runner = intruder;
}
}
void setupDefender(ArrayShape arrayType, List defenderArray) {
var topArray = defenderArray.map((e) => RunningFighter(e)).toList();
for (int i = 0; i < topArray.length; i++) {
var intruder = topArray[i];
var pair = arrayType.arraySlot[i];
topSlotList.firstWhere((e) => e.x == pair.x && e.y == pair.y).runner = intruder;
}
}
提供方法开始一场大汗淋漓的战斗吧
战斗的逻辑如下:
- 遍历所有槽位,找出下一个行动者,判断依据为蓄力池已满的战斗者,如果同时有多个战斗者蓄力池已满,则溢出值最多的战斗者先行动。战斗者速度即为每个循环蓄力池的恢复速度,蓄力池容量为预设10000。
- 将行动者标记为行动状态,将对手方所有人标记为待选状态
- 等待用户输入
- 当用户点击对手方的其中一个后,将被选中的人标记为防守状态,所有待选状态的人标记为正常状态
- 行动者对防守者进行攻击操作,防守者失去生命值,数值为进攻方攻击值减去防守者防御值,下限为0,上限为防守者当前生命值
- 攻击操作执行结束后,将行动者和防守者标记为正常状态
- 回到1,开始下一轮战斗,直至某一方的人全部战败
Future begin() async {
await Future.delayed(const Duration(milliseconds: 1000));
var allArray = topSlotList.followedBy(bottomSlotList);
BattleSlot next;
while (next == null) {
allArray.forEach((e) {
e.runner?.restore();
if (e.runner != null && e.runner.lack < 0 && (next == null || e.runner.lack < next.runner.lack)) {
next = e;
}
});
}
next.markMotion();
var slotList = next.isIntruder ? topSlotList : bottomSlotList;
var callback = (BattleSlot selected) async {
selected.markDefense();
slotList.forEach((element) {
if (element != selected) element.markNone();
});
await Future.delayed(const Duration(milliseconds: 200));
next.attack(selected, () {
selected.markNone();
next.markNone();
/// loop begin next
begin();
});
};
slotList.forEach((e) => e.markPending(callback));
}
定义战斗槽 BattleSlot
定义在阵型中的位置,用一个x和y表示,以及bool值表示位于战场的上方还是下方
提供一个属性表示战斗槽在阵型中的相对左上角的位置,用于布局
提供一个属性表示战斗槽在屏幕中的位置,用于计算攻击时位移动画的终点
提供一个属性表示当前的状态
提供一个无参函数,当状态发生变化时调用,用来控制不同的显示效果,例如被标记为待选状态时,做出抖动效果,行动状态时的闪烁效果
提供一个包含了位置和回调函数的函数,当进攻方攻击防守方时调用,用来展现进攻方攻击的动画
提供一个包含了战斗槽的函数,当用户选择了某个待选状态的战斗槽时,调用它可以将用户的反馈传递给挂起的战斗过程,使之继续执行
class BattleSlot {
final int x, y;
/// 相对于以阵型左上为原点的位置,A阵以A02为原点,B阵以B00为原点
Rect relRect;
/// 屏幕坐标系上的位置
Rect absRect;
/// 是否为进攻方,A区为防守方,B区为进攻方
final bool isIntruder;
SlotStatus status;
RunningFighter runner;
Notify0 onStatusChanged;
Notify2 motion;
/// SlotStatus.PENDING状态的Slot被选中后的回调
Function(BattleSlot slot) callback;
BattleSlot(this.x, this.y, this.isIntruder);
@override
bool operator ==(other) {
return other is BattleSlot && other.y == this.y && other.x == this.x;
}
@override
int get hashCode => hashValues(x, y, isIntruder);
}
提供一系列方法来变更状态,其中标记行动时需要清空蓄力池。
void markMotion() {
status = SlotStatus.MOTION;
onStatusChanged?.call();
runner.lack += COOLING;
}
void markDefense() {
status = SlotStatus.DEFENSE;
onStatusChanged?.call();
}
void markNone() {
status = SlotStatus.NONE;
callback = null;
onStatusChanged?.call();
}
void markPending(void callback(BattleSlot slot)) {
if (runner == null) return;
this.callback = callback;
status = SlotStatus.PENDING;
onStatusChanged?.call();
}
提供方法来进行攻击
void attack(BattleSlot slot, void callback()) {
motion?.call(slot.absRect, callback);
}
提供方法来消费用户点击
void onTap(BattleSlot slot) => callback?.call(slot);
定义战场中的战斗者 RunningFighter
包含一个表示蓄力池的属性
包含一个表示当前生命值与最大生命值的属性。默认当前生命值和最大生命值均为战斗者的生命值,可以被各种buff影响(未实现)
包含一个蓄力速度属性,默认为战斗者的速度属性,可以被各种buff影响(未实现)
包含一个防御值属性,默认为战斗者的防御值,可以被各种buff影响(未实现)
包含一个攻击值属性,默认为战斗者的攻击值,可以被各种buff影响(未实现)
class RunningFighter {
final Fighter fighter;
/// 充满行动槽所需长度
double lack = COOLING;
RunningFighter(this.fighter);
}
提供一个方法来恢复蓄力池
void restore() {
lack -= fighter.sp;
}
定义战斗槽的状态 SlotStatus
enum SlotStatus { NONE, MOTION, DEFENSE, PENDING }
战场定义完毕,开始界面布局
战场布局
因为是demo,所以战场的创建就放在了initState函数中,包括创建敌人和玩家以及阵型(这部分内容在游戏世界内定义好了)。下篇会讲游戏世界的内容
战场界面包括一个开始按钮和上半区的阵型布局和下半区的阵型布局
class BattlefieldWidget extends StatefulWidget {
@override
_BattlefieldWidgetState createState() => _BattlefieldWidgetState();
}
class _BattlefieldWidgetState extends State {
Battlefield battlefield;
bool hasStart = false;
@override
void initState() {
super.initState();
battlefield = Battlefield()
..computeSlotPosition(320.p, 320.p)
..setupDefender(world.arrayTypeMap["A"], [
world.generateEnemy("E001"),
world.generateEnemy("E001"),
world.generateEnemy("E001"),
])
..setupIntruder(world.arrayTypeMap["B"], [
world.player,
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(children: [
Align(
alignment: Alignment.topCenter,
child: Container(width: 320.p, height: 320.p, child: ArrayWidget(battlefield.topSlotList)),
),
Align(
alignment: Alignment.bottomCenter,
child: Container(width: 320.p, height: 320.p, child: ArrayWidget(battlefield.bottomSlotList)),
),
if (!hasStart)
Container(
width: double.infinity,
height: double.infinity,
color: Colors.black26,
alignment: Alignment.center,
child: SmallButton("开始", () {
battlefield.begin();
setState(() => hasStart = true);
}),
)
]),
),
);
}
}
阵型布局
将战场中已定义好的战斗槽按其相对位置进行布局
class ArrayWidget extends StatelessWidget {
final List array;
const ArrayWidget(this.array, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
var mapping = (BattleSlot e) {
var container = Container(
width: e.relRect.width, height: e.relRect.height, alignment: Alignment.center, child: SlotWidget(slot: e));
return Positioned(left: e.relRect.left, top: e.relRect.top, child: container);
};
return Stack(children: array.map(mapping).toList());
}
}
战斗槽Widget
包含若干动画
class SlotWidget extends StatefulWidget {
final BattleSlot slot;
const SlotWidget({Key key, this.slot}) : super(key: key);
@override
_SlotWidgetState createState() => _SlotWidgetState();
}
class _SlotWidgetState extends State with SingleTickerProviderStateMixin {
String name = "";
AnimationController _controller;
Animation _animation;
Animation _positionFactor;
}
初始化设置槽位在屏幕中的位置
初始化设置监听状态变化
初始化设置监听攻击操作
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((d) {
var box = context.findRenderObject() as RenderBox;
Offset offset = box.localToGlobal(Offset.zero);
widget.slot.absRect = box.paintBounds.translate(offset.dx, offset.dy);
});
if (widget.slot.runner?.fighter is Player) {
name = (widget.slot.runner.fighter as Player).name;
} else if (widget.slot.runner?.fighter is Enemy) {
name = (widget.slot.runner.fighter as Enemy).type.name;
}
widget.slot.onStatusChanged = onStatusChanged;
widget.slot.motion = motion;
_controller = AnimationController(vsync: this, duration: Duration(milliseconds: 200));
_controller.addListener(() => setState(() {}));
}
处理状态变化
切换为正常状态时,关闭动画
切换为防守状态时,无需动画,所以也关闭动画
切换为行动状态时,设置颜色动画,通过开启重复来实现闪烁效果
切换为待选状态时,因为抖动效果在每次重绘时随机移动来实现,所以这里不需要设置动画,只需开启重复即可
void onStatusChanged() {
_controller.reset();
switch (widget.slot.status) {
case SlotStatus.NONE:
case SlotStatus.DEFENSE:
_controller.stop();
break;
case SlotStatus.MOTION:
_controller.duration = Duration(milliseconds: 400);
_animation = ColorTween(begin: Colors.white, end: Colors.red)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeInCubic));
_controller.repeat(reverse: true);
break;
case SlotStatus.PENDING:
_controller.repeat(reverse: true);
break;
}
}
处理攻击事件
计算偏移终点,并通过animateTo(1.5).then(animateTo(0.0))实现来回动画
void motion(Rect target, void callback()) {
var local = widget.slot.absRect;
var offset = local.center.dy > target.center.dy
? local.topCenter - target.bottomCenter
: local.bottomCenter - target.topCenter;
var end = Offset(-offset.dx / local.width, -offset.dy / local.height);
_positionFactor =
_controller.drive(Tween(begin: Offset.zero, end: end).chain(CurveTween(curve: Curves.easeInQuart)));
_controller.reset();
_controller.duration = Duration(milliseconds: 200);
_controller.animateTo(1.5).whenComplete(() => _controller.animateTo(0.0)).then((value) {
_positionFactor = null;
callback.call();
});
}
处理用户的点击事件
void onTap() {
widget.slot.onTap(widget.slot);
}
build实现
正常状态下,白底,无偏移,边框为0
待选状态下,四向随机偏移
防守状态下,边框为2
行动状态下,底色为前面定义的颜色动画值
进攻时,SlideTransition实现动画偏移
@override
Widget build(BuildContext context) {
var color = Colors.white;
var offset = Offset.zero;
var borderWidth = 0.0;
if (widget.slot.status == SlotStatus.PENDING) {
/// i: 0-> x=-2; 1-> x=2; 2-> y=-2; 3-> y=2;
var i = new Random().nextInt(4);
offset = offset.translate(i == 0 ? -2 : (i == 1 ? 2 : 0), i == 2 ? -2 : (i == 3 ? 2 : 0));
} else if (widget.slot.status == SlotStatus.DEFENSE) {
borderWidth = 2.0;
} else if (widget.slot.status == SlotStatus.MOTION) {
color = _animation.value;
}
var container = Container(
child: Text(name),
decoration: BoxDecoration(color: color, border: Border.all(color: Colors.green, width: borderWidth)),
width: widget.slot.relRect.width,
alignment: Alignment.center,
height: widget.slot.relRect.height);
var transform = Transform.translate(offset: offset, child: GestureDetector(onTap: onTap, child: container));
return _positionFactor != null ? SlideTransition(position: _positionFactor, child: transform) : transform;
}
写在最后
本人热爱编程,热爱游戏,热爱flutter,一直想用flutter写一个RPG类型的游戏。欢迎志同道合的朋友一起互相交流!