前情提要
到目前为止,我们的角色和敌人都只有一个静态的图片。但在真正的游戏中,角色会有不同的动作:站立、行走、跳跃等等。
这节课我们来实现一个简易的动画状态机,让角色的动作更加生动。
什么是动画状态机
动画状态机(Animation State Machine)是一个管理角色动画状态的系统。它的核心思想是:
- 角色在任何时刻都处于某个"状态"(比如站立、行走、跳跃)
- 每个状态对应一组动画帧
- 根据游戏逻辑,状态会发生切换
准备动画帧
首先,你需要准备多张图片,每张图片代表角色的一个动作帧。比如:
mario_stand.png- 站立mario_walk_1.png- 行走第 1 帧mario_walk_2.png- 行走第 2 帧mario_jump.png- 跳跃
如果没有这些图片,你可以先用同一张图片来测试,等有了图片资源再替换。
设计动画数据结构
我们需要一个对象来存储角色的所有动画状态:
const player = {
x: cvWidth / 2,
y: floorY,
speed: 200,
ySpeed: 0,
airCount: 0,
maxAirCount: 28,
// 动画相关
currentState: "stand", // 当前状态
animationFrame: 0, // 当前动画帧索引
animationTimer: 0, // 动画计时器
// 所有动画帧
animations: {
stand: {
frames: [], // 站立动画的所有帧
speed: 0, // 动画速度(0 表示不播放动画)
},
walk: {
frames: [], // 行走动画的所有帧
speed: 150, // 动画速度(毫秒/帧)
},
jump: {
frames: [], // 跳跃动画的所有帧
speed: 0,
},
},
};
加载动画帧
// 加载站立动画
const standImg = new Image();
standImg.src = "./images/mario_stand.png";
player.animations.stand.frames.push(standImg);
// 加载行走动画
const walk1Img = new Image();
walk1Img.src = "./images/mario_walk_1.png";
const walk2Img = new Image();
walk2Img.src = "./images/mario_walk_2.png";
player.animations.walk.frames.push(walk1Img, walk2Img);
// 加载跳跃动画
const jumpImg = new Image();
jumpImg.src = "./images/mario_jump.png";
player.animations.jump.frames.push(jumpImg);
获取当前应该显示的图片
我们需要一个函数来获取当前状态下应该显示的图片:
function getCurrentFrame(entity) {
const animation = entity.animations[entity.currentState];
if (!animation || animation.frames.length === 0) {
// 如果没有动画,返回一个默认图片
return entity.animations.stand.frames[0];
}
return animation.frames[entity.animationFrame];
}
更新动画帧
在 update 函数中,我们需要更新动画帧:
function updateAnimation(entity, modifier) {
const animation = entity.animations[entity.currentState];
if (!animation || animation.speed === 0 || animation.frames.length <= 1) {
// 如果没有动画,或者动画速度为 0,或者只有一帧,就不更新
return;
}
// 累加计时器
entity.animationTimer += modifier * 1000; // 转换为毫秒
// 如果计时器超过了动画速度,就切换到下一帧
if (entity.animationTimer >= animation.speed) {
entity.animationTimer = 0;
entity.animationFrame++;
// 如果超过了最后一帧,就回到第一帧
if (entity.animationFrame >= animation.frames.length) {
entity.animationFrame = 0;
}
}
}
然后在 update 函数中调用:
function update(modifier) {
// ... 其他更新逻辑 ...
// 更新动画
updateAnimation(player, modifier);
if (enemy.isAlive) {
updateAnimation(enemy, modifier);
}
}
根据游戏逻辑切换状态
现在我们需要根据角色的行为来切换状态:
function updatePlayerState() {
// 如果在空中,切换到跳跃状态
if (player.y < floorY) {
player.currentState = "jump";
}
// 如果在移动,切换到行走状态
else if ("KeyA" in keysDown || "KeyD" in keysDown) {
player.currentState = "walk";
}
// 否则切换到站立状态
else {
player.currentState = "stand";
}
}
在 update 函数中调用:
function update(modifier) {
// ... 移动逻辑 ...
// 更新角色状态
updatePlayerState();
// 更新动画
updateAnimation(player, modifier);
// ... 其他逻辑 ...
}
修改绘制函数
现在绘制角色时,需要使用 getCurrentFrame 函数:
function drawImg() {
// ... 其他绘制代码 ...
// 绘制角色
const playerImg = getCurrentFrame(player);
ctx.drawImage(
playerImg,
player.x - playerImg.width / 2,
player.y - playerImg.height / 2
);
// 绘制敌人
if (enemy.isAlive) {
const enemyImg = getCurrentFrame(enemy);
ctx.drawImage(
enemyImg,
enemy.x - enemyImg.width / 2,
enemy.y - enemyImg.height / 2
);
}
}
优化:状态切换时重置动画帧
当状态切换时,我们应该重置动画帧到第一帧:
function setState(entity, newState) {
if (entity.currentState !== newState) {
entity.currentState = newState;
entity.animationFrame = 0; // 重置到第一帧
entity.animationTimer = 0; // 重置计时器
}
}
然后修改 updatePlayerState 函数:
function updatePlayerState() {
if (player.y < floorY) {
setState(player, "jump");
} else if ("KeyA" in keysDown || "KeyD" in keysDown) {
setState(player, "walk");
} else {
setState(player, "stand");
}
}
添加左右朝向
在马里奥游戏中,角色会根据移动方向改变朝向。我们可以使用 Canvas 的 scale 方法来实现水平翻转:
function drawImg() {
// ... 其他绘制代码 ...
// 绘制角色
const playerImg = getCurrentFrame(player);
ctx.save(); // 保存当前状态
// 如果角色向左移动,就水平翻转
if (player.direction === "left") {
ctx.scale(-1, 1); // 水平翻转
ctx.drawImage(
playerImg,
-player.x - playerImg.width / 2, // 注意 X 坐标要取反
player.y - playerImg.height / 2
);
} else {
ctx.drawImage(
playerImg,
player.x - playerImg.width / 2,
player.y - playerImg.height / 2
);
}
ctx.restore(); // 恢复状态
}
记得在角色移动时更新方向:
if ("KeyA" in keysDown) {
player.x += -player.speed * modifier;
player.direction = "left";
}
if ("KeyD" in keysDown) {
player.x += player.speed * modifier;
player.direction = "right";
}
给敌人也添加动画
敌人的动画系统和角色是一样的:
const enemy = {
x: 500,
y: floorY,
moveSpeed: 50,
direction: 0,
isAlive: true,
ySpeed: 0,
// 动画相关
currentState: "walk",
animationFrame: 0,
animationTimer: 0,
animations: {
walk: {
frames: [],
speed: 200,
},
dead: {
frames: [],
speed: 0,
},
},
};
// 加载敌人动画
const enemyWalk1 = new Image();
enemyWalk1.src = "./images/bad_guy_walk_1.png";
const enemyWalk2 = new Image();
enemyWalk2.src = "./images/bad_guy_walk_2.png";
enemy.animations.walk.frames.push(enemyWalk1, enemyWalk2);
const enemyDead = new Image();
enemyDead.src = "./images/bad_guy_dead.png";
enemy.animations.dead.frames.push(enemyDead);
当敌人被踩死时,切换到死亡状态:
if (isStampOn(player, enemy)) {
setState(enemy, "dead");
enemy.isAlive = false;
player.ySpeed = 200;
gameData.score++;
setTimeout(() => {
respawnEnemy();
setState(enemy, "walk");
}, 1000);
}
小结
这节课我们学习了:
- 什么是动画状态机
- 如何设计动画数据结构
- 如何加载和管理多个动画帧
- 如何根据游戏逻辑切换动画状态
- 如何实现角色的水平翻转
- 如何给敌人也添加动画
通过动画状态机,我们的游戏角色变得更加生动了。在下一节课中,我们会完善游戏逻辑,添加游戏结束和重新开始的功能。