我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛
回忆
绿水灵、花蘑菇、蝙蝠魔、射手村、勇士部落、魔法密林、废弃都市、天空之城、枫叶盾...,这些名字是否勾起你对《冒险岛 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 个宽度的字符。统计出总行数以后计算文字部分高度。
接着绘制气泡的底边,相对于人物居中,然后绘制气泡主体,使用上面计算出来的文字总高度。再加上气泡的顶边,整个气泡就绘制完成了。
最后将我们分割好的文字一行一行绘制在气泡主体内,就完成了一次聊天气泡的绘制。
玩家的名称绘制跟聊天气泡的绘制如出一辙,就不详细讲解了。
整个角色的绘制就完成啦,看看实际效果:
角色状态切换
上面提到了一个切换角色状态的函数,它会在以下几种情况下被调用。
走
使指定角色切换到走路状态,根据玩家的操作设置定时器修改角色状态中的 x
、isFlip
,这两个值影响角色是向左走还是向右走,以及走动距离。
// 走路控制
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);
}
}
});
walkCtrl
、jumpCtrl
、changeRoleState
函数控制当前角色状态变化,并且通过 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,回忆了青春。
好想一觉醒来,还是那个拿着法杖的小法师。