回忆青春!WebSocket + Canvas 打造冒险岛聊天室

1,144 阅读7分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

回忆

绿水灵、花蘑菇、蝙蝠魔、射手村、勇士部落、魔法密林、废弃都市、天空之城、枫叶盾...,这些名字是否勾起你对《冒险岛 Online》的回忆呢?
第一次接触冒险岛是五年级在微机课上,同学偷偷下载了刚公测不久的《冒险岛 Online》,像素画风的角色与怪物,听不腻的 BGM、2D 卷轴模式下优秀的战斗体验,对涉世未深的笔者来说,是多么大的诱惑啊。

到现在也无法忘记与朋友在射手训练场求大佬送药水,在猪的海洋练级,在废弃都市做组队任务的快乐时光。

复刻

工作以后,玩游戏的时间少了,但还是放不下对《冒险岛 Online》的怀念,想要自己尝试复刻网页版,在了解其画面渲染机制以后发现,想要完全复刻,太难了,而且浏览器的性能可能也跟不上。
所以退而求其次,开发一个冒险岛聊天室,众所周知,《冒险岛 Online》就是一个站街聊天软件[滑稽]。

演示地址:chat.fmcat.top
开源地址:github.com/RongleCat/M…

演示地址所在的服务器带宽较小,各位看官看完了麻烦关闭浏览器页面,感谢。

技术选型

通信

既然是做聊天室,WebSocket 不能少,这边选用优秀轮子 socket.io 来完成服务端、客户端的数据通信。

渲染

其实在早些时候,笔者已经尝试过使用 HTML + CSS 的方案渲染聊天画面,可以完成,但是进入场景的角色过多的情况下,频繁操作 DOM 导致的卡顿和滞后严重影响体验,所以本次采用 Canvas 作为场景的载体。

控制

控制角色走动的时候,使用键盘按键较多,引入了一个成熟的按键绑定库 keyboard.js

素材

开发的技术咱们准备好了,但是没有图像素材的话,实属“巧妇难为无米之炊”,那么什么地方可以找到冒险岛的素材呢?

冒险岛的素材都存放在安装目录下后缀名为 .wz 的文件内,这其实是一个离线数据库,将数据与图片素材归类压缩保存。

本地纸娃娃工具可以读取.wz文件,根据用户搭配的造型、服饰、武器进行渲染,支持导出动作序列图、GIF 图。
但是随着游戏不断更新,现在市面上能使用的纸娃娃工具越来越少,需要找到对应游戏版本的工具。

好在还有大佬开发了在线纸娃娃系统,现在还在内测阶段,不过开放了一些内测用户搭配好的角色形象,可以直接下载序列图。

序列图展示

序列图中包括了角色不同动作、所有武器的攻击动作等等,我们目前只需要四个状态,共计 9 张图片,按照顺序横向拼成一张图。

角色素材

站立状态的序列帧顺序为:0 -> 1 -> 2 -> 0。

角色站立

走路状态的序列帧顺序为:3 -> 4 -> 5 -> 6,重复播放加上角色水平位置变化即可完成走动效果。

角色走动

文件命名的最后一段表示的是底部溢出高度,后续开发中有关键作用。

绘制场景

背景

只有角色难免有些单调,我们为聊天室添加一个背景,作为场景的载体。
整个聊天室大小为 800px * 600px,场景顶部至地面的高度为480px

背景

背景绘制代码如下:

const W = 800,
  H = 600;

//绘制舞台背景
function drawStage(ctx) {
  ctx.rect(0, 0, W, H);
  ctx.fillStyle = ctx.createPattern(images.bg, 'no-repeat');
  ctx.fill();
}

drawStage(context);

角色

let newUser = {
  roleImg: data.roleImagesName,
  state: {
    x: 20,
    y: 0,
    imageIndex: 0,
    isFlip: false,
    isJump: false,
    isWalk: false,
  },
  name: data.userName,
  chatText: '',
};

上面是一个新加入聊天室的玩家初始的状态

roleImage: 玩家选择的角色图片名称
x:初始的水平位置
y:初始的垂直位置(基于背景地面位置)
imageIndex:角色当前绘制的雪碧图下标
isFlip:角色是否需要水平翻转
isJump:角色是否在跳跃状态
isWalk:角色是否在走路状态
name:玩家名称
chatText:玩家发言文本

//更改人物状态
function changeRoleState(roleanme, state) {
  window.clearInterval(timers[roleanme]);
  var rule = [0, 1, 2, 1],
    fun,
    speed = 0,
    index = 1;
  if (state == 0) {
    console.log('站');
    speed = 500;
    roles[roleanme].state.imageIndex = 0;
    fun = function () {
      if (index == rule.length) {
        index = 0;
      }
      roles[roleanme].state.imageIndex = rule[index++];
    };
  } else if (state == 1) {
    console.log('趴');
    speed = 0;
    fun = function () {
      index = 0;
      roles[roleanme].state.imageIndex = 8;
    };
  } else if (state == 2) {
    console.log('走');
    speed = 150;
    rule = [3, 4, 5, 6];
    roles[roleanme].state.imageIndex = 1;
    fun = function () {
      if (index == rule.length) {
        index = 0;
      }
      roles[roleanme].state.imageIndex = rule[index++];
    };
  } else if (state == 3) {
    console.log('跳');
    speed = 0;
    fun = function () {
      index = 0;
      roles[roleanme].state.imageIndex = 7;
    };
  }
  timers[roleanme] = setInterval(fun, speed);
}

上面的函数用于切换当前角色的状态、播放速度以及当前序列帧下标,绘制器会根据当前角色状态将角色绘制在 Canvas 上。

人物绘制的 Y 轴位置为 480px - 角色图片高度 - 角色状态中的 y 值 + 底部溢出高度

//根据人物数组绘制所有人物
function drawRole(context) {
  for (var i in roles) {
    if (roles[i].name == myName) {
      myRole = roles[i];
      continue;
    }
    draw(roles[i], context);
  }

  // 最后绘制自己的角色
  if (myName) {
    draw(roles[myName], context);
  }

  // 单个角色绘制函数
  function draw(item, context) {
    var img = images[item.roleImg];
    var role = item;
    var deviationY = parseInt(item.roleImg.split('_')[3]);
    var roleWidth = img.width / 9;
    var nameWidth = 0;
    var count = 0;
    for (var i = 0; i < role.name.length; i++) {
      if (/[^\x00-\xff]/.test(role.name[i])) {
        count += 2;
      } else {
        count += 1;
      }
    }
    nameWidth = count * 6;

    // 画人物动作
    context.save();
    // 判断是否向左走,向左走得水平翻转人物,此处代码可再优化精简
    if (role.state.isFlip) {
      context.translate(800, 0);
      context.scale(-1, 1);
      context.drawImage(
        img,
        (role.state.imageIndex * img.width) / 9,
        0,
        img.width / 9,
        img.height,
        800 - (role.state.x + img.width / 9),
        480 - img.height - role.state.y + deviationY,
        img.width / 9,
        img.height
      );
    } else {
      context.drawImage(
        img,
        (role.state.imageIndex * img.width) / 9,
        0,
        img.width / 9,
        img.height,
        role.state.x,
        480 - img.height - role.state.y + deviationY,
        img.width / 9,
        img.height
      );
    }

    // 绘制角色名字
    context.restore();
    // 画人物名字框

    context.beginPath();
    context.lineJoin = 'round';
    context.lineWidth = 8;
    context.strokeStyle = 'rgba(0,0,0,.4)';
    context.strokeRect(
      role.state.x + (roleWidth - nameWidth) / 2,
      480 + 8 - role.state.y,
      nameWidth,
      8
    );
    // 写人物名字
    context.font = '12px 宋体';
    context.fillStyle = '#fff';
    context.fillText(
      role.name,
      role.state.x + (roleWidth - nameWidth) / 2,
      480 + 16 - role.state.y
    );
    context.closePath();
    // 如果有聊天文字,则开始绘制聊天框和文字
    if (role.chatText.length !== 0) {
      var length = role.chatText.length;
      drawChatText(
        context,
        role.name + ':' + role.chatText,
        role.state.x,
        role.state.y - deviationY,
        img.width / 9,
        img.height
      );
    }
  }
}

根据 Canvas 绘制规则,最先绘制的内容会在最下面,为了保持自己的角色在最顶层,所以把自己放在最后一个绘制。

聊天气泡

接下来是绘制聊天气泡,在角色状态的 chatText 字段不为空时,会走绘制聊天气泡的逻辑。
首先咱们来看看聊天气泡的素材,由三部分组成。

聊天气泡素材

// 聊天文字渲染
function drawChatText(context, text, x, y, roleWidth, roleHeight) {
  var len = text.length,
    count = 0,
    line = [],
    oneLine = '';
  // 单行文字截取,一行14个字节长度。中文=2,英文=1
  for (var i = 0; i < len; i++) {
    if (/[^\x00-\xff]/.test(text[i])) {
      count += 2;
    } else {
      count += 1;
    }
    oneLine += text[i];
    if (count == 13) {
      if (/[^\x00-\xff]/.test(text[++i])) {
        line.push(oneLine);
        count = 0;
        oneLine = '';
        i--;
      }
    } else if (count == 14) {
      line.push(oneLine);
      oneLine = '';
      count = 0;
    }
  }
  line.push(oneLine);

  var textHeight = line.length * 16,
    starY = 480 - y - roleHeight - images.chat_bg3.height,
    startX = x + (roleWidth - images.chat_bg3.width) / 2;
  // 画聊天框底边
  context.drawImage(
    images.chat_bg3,
    startX,
    starY,
    images.chat_bg3.width,
    images.chat_bg3.height
  );
  // 画聊天框内容背景
  for (var i = 1; i < textHeight + 1; i++) {
    context.drawImage(
      images.chat_bg2,
      startX,
      starY - i,
      images.chat_bg2.width,
      images.chat_bg2.height
    );
  }
  // 画聊天框顶边
  context.drawImage(
    images.chat_bg1,
    startX,
    starY - textHeight - images.chat_bg1.height,
    images.chat_bg1.width,
    images.chat_bg1.height
  );
  //在聊天框上写字
  for (var i = 0; i < line.length; i++) {
    context.beginPath();
    context.font = '12px 宋体';
    context.fillStyle = '#000';
    context.fillText(
      line[i],
      5 + startX,
      starY - textHeight - images.chat_bg1.height + (i + 1) * 16
    );
    context.closePath();
  }
}

首先获取到文本内容,根据 中文字符宽度为 2,非中文字符宽度为 1 计算字符总行数,一行可容纳的字符 14 个宽度的字符。统计出总行数以后计算文字部分高度。

接着绘制气泡的底边,相对于人物居中,然后绘制气泡主体,使用上面计算出来的文字总高度。再加上气泡的顶边,整个气泡就绘制完成了。

最后将我们分割好的文字一行一行绘制在气泡主体内,就完成了一次聊天气泡的绘制。

玩家的名称绘制跟聊天气泡的绘制如出一辙,就不详细讲解了。

整个角色的绘制就完成啦,看看实际效果:

角色绘制

角色状态切换

上面提到了一个切换角色状态的函数,它会在以下几种情况下被调用。

使指定角色切换到走路状态,根据玩家的操作设置定时器修改角色状态中的 xisFlip,这两个值影响角色是向左走还是向右走,以及走动距离。

// 走路控制
function walkCtrl(name, state) {
  //如果状态是停止则清除定时器
  if (state === 'stop') {
    window.clearInterval(timers[name + 'walk']);
    if (name) {
      roles[name].state.isWalk = false;
      changeRoleState(name, 0);
    }
  } else {
    var roleWidth = images[roles[name].roleImg].width / 9;
    // 先设置人物图片状态
    changeRoleState(name, 2);
    roles[name].state.isWalk = true;
    timers[name + 'walk'] = setInterval(function () {
      // 判断左右更改人物水平位置
      if (state === 'right') {
        roles[name].state.isFlip = false;
        if (roles[name].state.x + roleWidth + 2 >= W) {
          roles[name].state.x = W - roleWidth;
        } else {
          roles[name].state.x += 4;
        }
      } else {
        roles[name].state.isFlip = true;
        if (roles[name].state.x <= 0) {
          roles[name].state.x = 0;
        } else {
          roles[name].state.x -= 4;
        }
      }
    }, 1000 / 24);
  }
}

// 控制跳
function jumpCtrl(name) {
  var step = 12;
  if (roles[name].state.isJump) {
    return false;
  }
  roles[name].state.isJump = true;
  changeRoleState(name, 3);
  window.clearInterval(timers[name + 'jump']);
  timers[name + 'jump'] = setInterval(function () {
    roles[name].state.y += step;
    step -= 1.5;
    if (roles[name].state.y <= 0) {
      roles[name].state.y = 0;
      window.clearInterval(timers[name + 'jump']);
      if (roles[name].state.isWalk) {
        changeRoleState(name, 2);
      } else {
        changeRoleState(name, 0);
      }
      roles[name].state.isJump = false;
    }
  }, 1000 / 24);
}

趴、站

这两个状态无额外信息修改只需要调用上面的 changeRoleState 函数切换到对应状态即可。

通信

WebSocket 以及 socket.io 的普及文站内数不胜数,这边就不再赘述了,主要讲一下玩家状态同步相关的内容。

上面有提到,笔者之前尝试过一个 HTML + CSS 的方案,这个方案的状态同步是所有客户端在建立角色之后,每秒 24 次通过服务器向其他客户端广播当前角色状态,各客户端根据接收到的角色信息调整场景中对应角色的展示。
这个方案可以很好的同步所有角色的状态,但是对服务器资源,客户端 DOM 渲染压力都很大。

后面经过高人指点,在 Canvas 版开发的时候,使用指令的方式传达角色状态更改,在玩家没有操作角色时,不广播自身状态。
玩家操作时,根据操作的类型广播指令.

// 跳
jumpCtrl(myName);
socket.emit('actionCtrl', {
  actionName: 'jump',
  actionValue: '',
  actionUser: myName,
});

// 趴下
changeRoleState(myName, 1);
socket.emit('actionCtrl', {
  actionName: 'down',
  actionValue: 'down',
  actionUser: myName,
});

// 起身,恢复站立状态
changeRoleState(myName, 0);
socket.emit('actionCtrl', {
  actionName: 'down',
  actionValue: 'up',
  actionUser: myName,
});

// 向左走
walkCtrl(myName, 'left');
socket.emit('actionCtrl', {
  actionName: 'walk',
  actionValue: 'left',
  actionUser: myName,
});

// 向右走
walkCtrl(myName, 'right');
socket.emit('actionCtrl', {
  actionName: 'walk',
  actionValue: 'right',
  actionUser: myName,
});

// 停止走动
walkCtrl(myName, 'stop');
socket.emit('actionCtrl', {
  actionName: 'walk',
  actionValue: 'stop',
  actionUser: myName,
});

// 接收其他客户端操作指令后的响应
socket.on('actionCtrl', function (action) {
  if (action.actionName === 'walk') {
    walkCtrl(action.actionUser, action.actionValue);
  } else if (action.actionName === 'jump') {
    jumpCtrl(action.actionUser);
  } else if (action.actionName === 'down') {
    if (action.actionValue === 'down') {
      changeRoleState(action.actionUser, 1);
    } else if (action.actionValue === 'up') {
      changeRoleState(action.actionUser, 0);
    }
  }
});

walkCtrljumpCtrlchangeRoleState 函数控制当前角色状态变化,并且通过 socket 向其他客户端广播当前玩家的信息。
其他客户端在接收到操作指令后,按照指令类型、指令值执行对应的函数改动指定角色的状态,渲染器根据玩家状态绘制画面。

这样,所有客户端都可以同步玩家状态的修改了,但是在实际情况下,受网络延迟、丢包等种种情况影响,各客户端渲染的画面并不能完全一致。
所以增加了一个定时广播自身角色状态的机制,用于修正同一角色在多客户端下不同步的情况。

控制

绘制、通信都完成以后,现在只需要让玩家可以通过键盘操作角色就行了。

//跳跃
keyboardJS.bind('space', function (e) {
  jumpCtrl(myName);
  socket.emit('actionCtrl', {
    actionName: 'jump',
    actionValue: '',
    actionUser: myName,
  });
});

//向左走
keyboardJS.bind(
  'left',
  function (e) {
    if (!e.repeat && !isWalk) {
      isWalk = true;
      walkCtrl(myName, 'left');
      socket.emit('actionCtrl', {
        actionName: 'walk',
        actionValue: 'left',
        actionUser: myName,
      });
    }
  },
  function () {
    isWalk = false;
    walkCtrl(myName, 'stop');
    socket.emit('actionCtrl', {
      actionName: 'walk',
      actionValue: 'stop',
      actionUser: myName,
    });
  }
);
// 下面还有很多按键的绑定,大同小异...

结语

至此,冒险岛聊天室就完成了。
很有意思的一个小项目,让笔者学习了 WebSocket,巩固了 Canvas,回忆了青春。
好想一觉醒来,还是那个拿着法杖的小法师。