这是我参与8月更文挑战的第4天,活动详情查看:8月更文挑战
前言
一开始就奔着月亮去,
就算失败
也许能收获一颗星星呢。
——克莱门特·斯通
介绍
好久没见过萤火虫了。想想童年,在那满是小小流萤的树林里,在黑沉沉的暮色里,他们多么欢乐地展开翅膀!他不是太阳也不是月亮,像繁星一样,带来无尽的欢乐。
虚拟摇杆在移动端游戏,尤其RPG游戏是及其重要的一个组件。我们本期开发一个虚拟摇杆,并且通过它去控制萤火虫的移动。我们大体会分为基础场景的搭建,背景和萤火虫的绘制,虚拟摇杆的实现来完成本次场景。
开发
1.基础场景的搭建
<style>
* {
padding: 0;
margin: 0;
}
html,
body {
width: 100%;
height: 100vh;
overflow: hidden;
}
#canvas{
width: 100%;
height: 100%;
}
</style>
<body>
<canvas id="canvas"></canvas>
<script type="module" src="./app.js"></script>
</body>
复制代码
这里我们依旧基础结构先写出来,利用module模式来导入模块。
接下来先把rocker定义好然后引入。
/*rocker.js*/
class Rocker {
constructor(options) {}
render(ctx) {
return this;
}
}
export default Rocker;
复制代码
我们先开始写主场景逻辑,其实应该把萤火虫单独分出一个类去写,然后实例化去控制的,但是因为整个场景比较小而且本次重点在虚拟摇杆部分所以跟主场景合在一起来写。
/*app.js*/
import Rocker from "./js/rocker.js";
class Application {
constructor() {
this.canvas = null; // 画布
this.ctx = null; // 环境
this.w = 0; // 画布宽
this.h = 0; // 画布高
this.r = 64; // 萤火虫发光大小
this.x = 0; // 萤火虫x轴坐标
this.y = 0; // 萤火虫y轴坐标
this.speed = 1; // 萤火虫移动速度
this.textures = new Map(); // 纹理集
this.spriteData = new Map(); // 精灵数据
this.rocker = null; // 摇杆对象
this.angle = 0; // 当前角度
this.gradient = null; // 萤火虫发光渐变值
this.init();
}
init() {
// 初始化
this.canvas = document.getElementById("canvas");
this.ctx = canvas.getContext("2d");
window.addEventListener("resize", this.reset.bind(this));
this.reset();
this.textures.set("rocker0", "../assets/rocker0.png");
this.textures.set("rocker1", "../assets/rocker1.png");
this.load().then(this.render.bind(this));
}
reset() {
// 屏幕改变事件
this.w = this.canvas.width = window.innerWidth;
this.h = this.canvas.height = window.innerHeight;
this.x = (this.w - this.r) / 2;
this.y = (this.h - this.r) / 2;
}
load(){
// 加载纹理
const {textures, spriteData} = this;
let n = 0;
return new Promise((resolve, reject) => {
for (const key of textures.keys()) {
let _img = new Image();
spriteData.set(key, _img);
_img.onload = () => {
if (++n == textures.size)
resolve();
}
_img.src = textures.get(key);
}
})
}
render() {
// 主渲染
const {spriteData, w, h, ctx} = this;
this.rocker = new Rocker({
w,
h,
spriteData,
}).render(ctx);
this.step();
}
drawBackground(){
// 绘制背景
}
drawBall(){
// 绘制萤火虫
}
step(delta) {
// 重绘
const {w, h, ctx} = this;
requestAnimationFrame(this.step.bind(this));
ctx.clearRect(0, 0, w, h);
this.drawBackground();
this.drawBall();
this.rocker && this.rocker.draw();
}
}
window.onload = new Application();
复制代码
我们在主场景逻辑定义好了各类变量和渲染重绘事件,并且先把两张虚拟摇杆用到的图片加载好。
虚拟摇杆图片分为外边圆环和内摇杆块,如图:
加载完图片就会进入主渲染render方法会实例化虚拟摇杆,然后再执行重绘操作,在重绘里面渲染图片和萤火虫以及虚拟摇杆。
2.背景和萤火虫的绘制
// 绘制背景
drawBackground() {
const {w, h, ctx} = this;
ctx.fillStyle = "rgb(24,13,50)";
ctx.fillRect(0, 0, w, h);
}
复制代码
// 绘制萤火虫
drawBall() {
const {x, y, ctx, r} = this;
ctx.save();
if (!this.gradient) {
this.setGradient()
}
ctx.fillStyle = this.gradient;
ctx.translate(x, y);
ctx.fillRect(0, 0, r, r);
ctx.restore();
}
复制代码
在我们绘制萤火虫的时候期望他的填充色是一个渐变的从内而外发光的效果。所以接下来,我们要写出setGradient方法来改变渐变值gradient来付给fillStyle
// 设置渐变色
setGradient(n = 0) {
const {ctx, r} = this;
this.gradient = ctx.createRadialGradient(r / 2, r / 2, 0, r / 2, r / 2, r / 2)
this.gradient.addColorStop(0, 'rgba(255,255,128,1)');
this.gradient.addColorStop(0.2 - n, `rgba(215,215,0,${0.7 - n * 2})`);
this.gradient.addColorStop(0.5 - n * 2, `rgba(245,245,0,${0.3 - n})`);
this.gradient.addColorStop(0.7 - n, 'rgba(255,255,255,0)');
}
复制代码
在渐变色不能写死,因为萤火虫飞起来翅膀扇动,会略微影响发光面积,所以在重绘的时候我们再给他个值模拟他的状态改变。
step(delta) {
const {w, h, ctx} = this;
requestAnimationFrame(this.step.bind(this));
ctx.clearRect(0, 0, w, h);
this.drawBackground();
this.drawBall();
this.rocker && this.rocker.draw();
// + 每隔3取模闪动一次
if (~~delta % 3 == 0) {
this.setGradient(0.1)
} else {
this.setGradient()
}
}
复制代码
这里我们就可以看到画面了,萤火虫不断在屏幕中间闪动。
3.虚拟摇杆的实现
我们接下来要实现一个虚拟摇杆的类,然后通过它的实例化控制萤火虫使其移动。
class Rocker {
constructor(options) {
this.w = 0; // 当前所在场景的宽
this.h = 0; // 当前所在场景的高
this.D = 180; // 外圆环直径
this.d = 60; // 滑块直径
this.spriteData = null; // 精灵图片数据
Object.assign(this, options);
this.x = 0; // 滑块x轴坐标
this.y = 0; // 滑块y轴坐标
this.centerX = 0; // 中心点x轴坐标
this.centerY = 0; // 中心点y轴坐标
this.padding = 20; // 外环距离底部的距离
this.isActive = false; // 是否触发
this.angle = 0; // 当前角度
this.event = new Map(); // 事件字典
return this;
}
render(ctx) {
// 主渲染
const {d, h, D, padding} = this;
this.ctx = ctx;
this.x = this.centerX = padding + D / 2; // 计算中心点x轴坐标
this.y = this.centerY = h - padding - D / 2; // 计算中心点y轴坐标
this.draw();
let canvas = ctx.canvas; // 获取当前场景画布
// 判断设备环境
// 1.移动端用触摸事件
if (navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i)) {
canvas.addEventListener("touchstart", this._mousedown.bind(this));
canvas.addEventListener("touchmove", this._mousemove.bind(this));
canvas.addEventListener("touchend", this._mouseup.bind(this));
}
// 2.pc端用鼠标事件
else {
canvas.addEventListener("mousedown", this._mousedown.bind(this));
canvas.addEventListener("mousemove", this._mousemove.bind(this));
canvas.addEventListener("mouseup", this._mouseup.bind(this));
canvas.addEventListener("mouseout", this._mouseup.bind(this));
}
return this;
}
on(eventName, callback) {
// 伪订阅消息
this.event.set(eventName, (...args) => callback && callback(...args))
}
_mousedown(e) {
// 输入设备按下
const {d} = this;
let x = e.offsetX || e.changedTouches[0].clientX;
let y = e.offsetY || e.changedTouches[0].clientY;
if ((x > this.x - d / 2 && x < this.x + d / 2) && (y > this.y - d / 2 && y < this.y + d / 2)) {
this.isActive = true;
}
}
_mousemove(e) {
// 输入设备移动
if (this.isActive) {
let x = e.offsetX || e.changedTouches[0].clientX;
let y = e.offsetY || e.changedTouches[0].clientY;
this.limitToCircle({
x,
y
}, this.D / 2 - this.d / 2);
}
}
_mouseup(e) {
// 输入设备抬起或离开
this.isActive = false;
this.x = this.centerX;
this.y = this.centerY;
}
limitToCircle(pos, limitRadius) {
// 判断滑动边界
const {centerX, centerY} = this;
let dx = pos.x - centerX;
let dy = pos.y - centerY;
let _r = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
this.angle = Math.atan2(dy, dx);
if (this.event.has("change")) this.event.get("change")(this.angle, Math.min(_r,limitRadius) / limitRadius);
if (_r < limitRadius) {
this.x = pos.x;
this.y = pos.y;
} else {
this.x = centerX + Math.cos(this.angle) * limitRadius;
this.y = centerY + Math.sin(this.angle) * limitRadius;
}
}
draw() {
// 主绘制
this.drawOuter();
this.drawCenter();
}
drawOuter() {
// 绘制外层圆环
const {ctx, spriteData, D, h, padding} = this;
ctx.save();
ctx.translate(padding, h - D - padding);
ctx.drawImage(spriteData.get("rocker0"), 0, 0, D, D);
ctx.restore();
}
drawCenter() {
// 绘制中心滑块
const {ctx, spriteData, d, x, y} = this;
ctx.save();
ctx.translate(x - d / 2, y - d / 2);
ctx.drawImage(spriteData.get("rocker1"), 0, 0, d, d);
ctx.restore();
}
}
export default Rocker;
复制代码
以上是虚拟摇杆的完整代码。
虚拟摇杆要经过这么几个过程:
- 绘制:这里不做赘述,就是放上加载好的图片,然后定位上去。
- 输入事件:监听输入设备的操作,pc端用鼠标事件监听,移动端用触摸事件监听。所监听的当前点击的是摇杆的滑块,就会激活它用isActive做开关去判断。如果按住且移动的话就不断给他改变坐标,使之看起来跟着输入设备拖动。最后弹起关闭isActive使其失活,回归最初的中心点。
- 滑动边界与获取角度:当滑动的时候不让不判断他的极值随时能画出并且如果没有角度我们也没发去给到萤火虫移动的方位。这里我们设置好最大范围limitRadius,再利用两点间距离公司求出当前点到圆心间的距离,如果这个距离大于limitRadius。那么我们还要求出在他方向上圆环的极值点。不过本来也要利用Math.atan2算出x轴在拖动当前点的角度。我们就可以利用这个角度根据三角函数配合limitRadius作为半径,就可以得出所在当前范围的极值点。最后如果大于limitRadius,就把这个极值点返给他,从而现在滑动范围。
- 发布订阅:这里我们为了方便,我们只做了简单的一层就当外界注入change事件后我们收集起来。再当滑块的时候判断这个改变事件是否存在,如果存在就把所在的角度和滑动力度返给外界。
最后我们在主逻辑上,再次修改主渲染逻辑给他加入虚拟摇杆改变位置事件的监听。
render() {
const {spriteData, w, h, ctx} = this;
this.rocker = new Rocker({
w,
h,
spriteData,
}).render(ctx);
this.rocker.on("change", (angle, speed) => {
this.angle = angle
this.speed = speed * 3;
})
this.step();
}
复制代码
这样我们就得到了角度和速度。
我们接下来,就可以在萤火虫绘制上来改变他的坐标了。
drawBall() {
const {x, y, ctx, r, angle, speed} = this;
if (this.rocker && this.rocker.isActive) {
let vx = speed * Math.cos(angle);
let vy = speed * Math.sin(angle);
this.x += vx;
this.y += vy;
}
ctx.save();
if (!this.gradient) {
this.setGradient()
}
ctx.fillStyle = this.gradient;
ctx.translate(x, y);
ctx.fillRect(0, 0, r, r);
ctx.restore();
}
复制代码
很简单,就是判断当前虚拟摇杆如果激活,利用三角函数与其方向和速度上计算出x与y轴的增量使其位移。
写到这里,我们已经从0开始实现了以一个虚拟摇杆并且通过它控制萤火虫的移动,在线演示
拓展与延伸
有了虚拟摇杆这个工具,可以做各式各样的移动端rpg游戏了。但这个涉及到的数学知识有更多的延伸,可以带来更多的想法,比如范围内物体的检测,或者道具的拖动,战棋游戏角色范围内的移动。。
其实这个教程最早是设想要写雅木茶的操气弹的通过虚拟摇杆控制气弹,但是怕扑街。。就换做萤火虫了。