前情提要

到目前为止,我们的角色和敌人都只有一个静态的图片。但在真正的游戏中,角色会有不同的动作:站立、行走、跳跃等等。

这节课我们来实现一个简易的动画状态机,让角色的动作更加生动。

什么是动画状态机

动画状态机(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);
}

小结

这节课我们学习了:

  1. 什么是动画状态机
  2. 如何设计动画数据结构
  3. 如何加载和管理多个动画帧
  4. 如何根据游戏逻辑切换动画状态
  5. 如何实现角色的水平翻转
  6. 如何给敌人也添加动画

通过动画状态机,我们的游戏角色变得更加生动了。在下一节课中,我们会完善游戏逻辑,添加游戏结束和重新开始的功能。

下载完整代码

Leason9.zip