回忆 绿水灵、花蘑菇、蝙蝠魔、射手村、勇士部落、魔法密林、废弃都市、天空之城、枫叶盾…,这些名字是否勾起你对《冒险岛 Online》的回忆呢? 第一次接触冒险岛是五年级在微机课上,同学偷偷下载了刚公测不久的《冒险岛 Online》,像素画风的角色与怪物,听不腻的 BGM、2D 卷轴模式下优秀的战斗体验,对涉世未深的笔者来说,是多么大的诱惑啊。
到现在也无法忘记与朋友在射手训练场求大佬送药水,在猪的海洋练级,在废弃都市做组队任务的快乐时光。
复刻 工作以后,玩游戏的时间少了,但还是放不下对《冒险岛 Online》的怀念,想要自己尝试复刻网页版,在了解其画面渲染机制以后发现,想要完全复刻,太难了,而且浏览器的性能可能也跟不上。 所以退而求其次,开发一个冒险岛聊天室,众所周知,《冒险岛 Online》就是一个站街聊天软件[滑稽]。
演示地址:https://chat.fmcat.top 开源地址:https://github.com/RongleCat/Maple-Story-canvas
演示地址所在的服务器带宽较小,各位看官看完了麻烦关闭浏览器页面,感谢。
技术选型 通信 既然是做聊天室,WebSocket 不能少,这边选用优秀轮子 socket.io 来完成服务端、客户端的数据通信。
渲染 其实在早些时候,笔者已经尝试过使用 HTML + CSS 的方案渲染聊天画面,可以完成,但是进入场景的角色过多的情况下,频繁操作 DOM 导致的卡顿和滞后严重影响体验,所以本次采用 Canvas 作为场景的载体。
控制 控制角色走动的时候,使用键盘按键较多,引入了一个成熟的按键绑定库 keyboard.js 。
素材 开发的技术咱们准备好了,但是没有图像素材的话,实属“巧妇难为无米之炊”,那么什么地方可以找到冒险岛的素材呢?
冒险岛的素材都存放在安装目录下后缀名为 .wz
的文件内,这其实是一个离线数据库,将数据与图片素材归类压缩保存。
本地纸娃娃工具可以读取.wz
文件,根据用户搭配的造型、服饰、武器进行渲染,支持导出动作序列图、GIF 图。 但是随着游戏不断更新,现在市面上能使用的纸娃娃工具越来越少,需要找到对应游戏版本的工具。
好在还有大佬开发了在线纸娃娃系统 ,现在还在内测阶段,不过开放了一些内测用户搭配好的角色形象,可以直接下载序列图。
序列图中包括了角色不同动作、所有武器的攻击动作等等,我们目前只需要站
、走
、跳
、趴
四个状态,共计 9 张图片,按照顺序横向拼成一张图。
站立状态的序列帧顺序为:0 -> 1 -> 2 -> 0。
走路状态的序列帧顺序为:3 -> 4 -> 5 -> 6,重复播放加上角色水平位置变化即可完成走动效果。
文件命名的最后一段表示的是底部溢出高度 ,后续开发中有关键作用。
绘制场景 背景 只有角色难免有些单调,我们为聊天室添加一个背景,作为场景的载体。 整个聊天室大小为 800px * 600px
,场景顶部至地面的高度为480px
。
背景绘制代码如下:
1 2 3 4 5 6 7 8 9 10 11 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);
角色 1 2 3 4 5 6 7 8 9 10 11 12 13 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 :玩家发言文本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 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 值 + 底部溢出高度
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 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
字段不为空时,会走绘制聊天气泡的逻辑。 首先咱们来看看聊天气泡的素材,由三部分组成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 function drawChatText (context, text, x, y, roleWidth, roleHeight ) { var len = text.length, count = 0 , line = [], oneLine = '' ; 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
,这两个值影响角色是向左走还是向右走,以及走动距离。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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 ); } }
跳 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 版开发的时候,使用指令的方式传达角色状态更改,在玩家没有操作角色时,不广播自身状态。 玩家操作时,根据操作的类型广播指令.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 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 向其他客户端广播当前玩家的信息。 其他客户端在接收到操作指令后,按照指令类型、指令值执行对应的函数改动指定角色的状态,渲染器根据玩家状态绘制画面。
这样,所有客户端都可以同步玩家状态的修改了,但是在实际情况下,受网络延迟、丢包等种种情况影响,各客户端渲染的画面并不能完全一致。 所以增加了一个定时广播自身角色状态的机制,用于修正同一角色在多客户端下不同步的情况。
控制 绘制、通信都完成以后,现在只需要让玩家可以通过键盘操作角色就行了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 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,回忆了青春。 好想一觉醒来,还是那个拿着法杖的小法师。