本文原载于 SegmentFault 专栏 一个会小程序开发的iOSer
层叠拼图Plus
是一款需要空间想象力和逻辑推理能力完美结合的微信小游戏。
偶消奇不消,在简单的游戏规则下却有着无数种可能性,需要你充分发挥想象力去探索。
看似简单却具有极大的挑战性和趣味性,这就是其魅力所在!温馨提示,体验后再阅读此文体验更佳哦!
Talk is cheap,Show me the code.
层叠拼图Plus
微信小游戏采用
js
+
canvas
实现,没有使用任何游戏引擎,对于初学者来说,也比较容易入门。
接下来,我将通过以下几个点循序渐进的讲解
层叠拼图Plus
微信小游戏的实现。
-
-
-
1 + 1 = 0,「偶消奇不消」的效果如何实现?
-
-
-
-
canvas 绘图时,会从两个物理像素的中间位置开始绘制并向两边扩散 0.5 个物理像素。
当设备像素比为 1 时,一个 1px 的线条实际上占据了两个物理像素
(每个像素实际上只占一半)
,由于不存在 0.5 个像素,所以这两个像素本来不应该被绘制的部分也被绘制了,于是 1 物理像素的线条变成了 2 物理像素,视觉上就造成了模糊。
绘图模糊的原因知道了,在微信小游戏里面又该如何解决呢?
const ratio = wx.getSystemInfoSync().pixelRatio
let ctx = canvas.getContext('2d')
canvas.width = screenWidth * ratio
canvas.height = screenHeight * ratio
ctx.fillStyle = 'black'
ctx.font = `${18 * ratio}px Arial`
ctx.fillText('我是清晰的文字', x * ratio, y * ratio)
ctx.fillStyle = 'red'
ctx.fillRect(x * ratio, y * ratio, width * ratio, height * ratio)
可以看到,我们先通过
wx.getSystemInfoSync().pixelRatio
获取设备的像素比
ratio
。
然后将在屏
Canvas
的宽度和高度按照所获取的像素比
ratio
进行放大,在绘制文字、图片的时候,坐标点
x
、
y
和所要绘制图形的
width
、
height
均需要按照像素比
ratio
进行缩放。
这样我们就可以清晰的在高清屏中绘制想要的文字、图片。
另外,需要注意的是,这里的
canvas
是由 weapp-adapter 预先调用
wx.createCanvas()
创建一个上屏
Canvas
,并暴露为一个全局变量 canvas。
任意一个多边形图形,是由多个平面坐标点所组成的图形区域。
在游戏画布内,我们以左上角为坐标原点
{x: 0, y: 0}
,一个多边形包含多个单位长度的平面坐标点。
如:
[{ x: 1, y: 3 }, { x: 5, y: 3 }, { x: 3, y: 5 }]
表示为一个三角形的区域,需要注意的是,
x
、
y
并不是真实的平面坐标值,而是通过屏幕宽度计算出来的单位长度。
在画布内的真实坐标值则为
{x: x * itemWidth, y: y * itemWidth}
。
export default class Block {
constructor() { }
init(points, itemWidth, ctx) {
this.points = []
this.itemWidth = itemWidth
this.ctx = ctx
for (let i = 0; i let point = points[i]
this.points.push({
x: point.x * this.itemWidth,
y: point.y * this.itemWidth
})
}
}
draw() {
this.ctx.globalCompositeOperation = 'xor'
this.ctx.fillStyle = 'black'
this.ctx.beginPath()
this.ctx.moveTo(this.points[0].x, this.points[0].y)
for (let i = 1; i this.points.length; i++) {
let point = this.points[i]
this.ctx.lineTo(point.x, point.y)
}
this.ctx.closePath()
this.ctx.fill()
}
}
let points = [
[{ x: 4, y: 5 }, { x: 8, y: 9 }, { x: 4, y: 9 }],
[{ x: 10, y: 8 }, { x: 10, y: 12 }, { x: 6, y: 12 }],
[{ x: 7, y: 4 }, { x: 11, y: 4 }, { x: 11, y: 8 }
]
]
points.map((sub_points) => {
let block = new Block()
block.init(sub_points, this.itemWidth, this.ctx)
block.draw()
})
1 + 1 = 0,是层叠拼图Plus小游戏玩法的精髓所在。
有经验的同学,也许一眼就发现了,
1 + 1 = 0
刚好符合通过
异或运算
得出的结果。
当然,细心的同学也可能已经发现,在
如何绘制任意多边形图形
这一章节内,有一句特殊的代码:
this.ctx.globalCompositeOperation = 'xor'
,也正是通过设置
CanvasContext
的
globalCompositeOperation
属性值为
xor
便实现了「偶消奇不消」的神奇效果。
globalCompositeOperation
是指
在绘制新形状时应用的合成操作的类型
讲到这里,我们已经知道如何在
Canvas
画布内绘制出偶消奇不消效果的层叠图形了,接下来我们来看下玩家如何移动选中的图形。
我们发现绘制出的图形对象并没有提供点击事件绑定之类的操作,那又如何判断玩家选中了哪个图形呢?
这里我们就需要去实现如何判断玩家触摸事件的
x
,
y
坐标在哪个多边形图形内部区域,从而判断出玩家选中的是哪一个多边形图形。
在
层叠拼图Plus
小游戏内,采用的是
回转数
法来判断玩家触摸点是否在多边形内部。
回转数
是拓扑学中的一个基本概念,具有很重要的性质和用途。
当然,展开讨论
回转数
的概念并不在该文的讨论范围内,我们仅需了解一个概念:
当回转数为 0 时,点在闭合曲线外部。
上面面这张图动态演示了回转数的概念:图中红色曲线关于点(人所在位置)的回转数为
2
。
-
计算所有夹角和。注意每个夹角都是有方向的,所以有可能是负值
最后根据角度累加值计算回转数。360°(2π)相当于一次回转。
在使用
JavaScript
实现时,需要注意以下问题:
-
JavaScript
的数只有
64
位双精度浮点这一种。对于三角函数产生的无理数,浮点数计算不可避免会造成一些误差,因此在最后计算回转数需要做取整操作。
-
通常情况下,平面直角坐标系内一个角的取值范围是 -π 到 π 这个区间,这也是
JavaScript
三角函数
Math.atan2()
返回值的范围。但
JavaScript
并不能
直接计算任意两条线的夹角,我们只能先计算两条线与
x
正轴夹角,再取两者差值。这个差值的结果就有可能超出
-π
到
π
这个区间,因此我们还需要处理差值超
出取值区间的情况。
isPointInPolygon(p, poly) {
let px = p.x,
py = p.y,
sum = 0
for (let i = 0, l = poly.length, j = l - 1; i let sx = poly[i].x,
sy = poly[i].y,
tx = poly[j].x,
ty = poly[j].y
if ((sx - px) * (px - tx) >= 0 &&
(sy - py) * (py - ty) >= 0 &&
(px - sx) * (ty - sy) === (py - sy) * (tx - sx)) {
return true
}
let angle = Math.atan2(sy - py, sx - px) - Math.atan2(ty - py, tx - px)
if (angle >= Math.PI) {
angle = angle - Math.PI * 2
} else if (angle <= -Math.PI) {
angle = angle + Math.PI * 2
}
sum += angle
}
return Math.round(sum / Math.PI) === 0 ? false : true
}
注:该章节内容图片均来自网络,如有侵权,请告知删除。另外有兴趣的同学可以使用其他方法来实现判断一个点是否在任意多边形内部。
通过前面的介绍我们可以知道,判断游戏结果是否正确其实就是比对玩家组合图形的
xor
结果与目标图形的
xor
结果。
那么如何求多个多边形
xor
的结果呢?
polygon-clipping 正是为此而生的。
它不仅支持
xor
操作,还有其他的比如:
union
,
intersection
,
difference
等操作。
在
层叠拼图Plus
游戏内通过 polygon-clipping 又是怎样实现游戏结果判断的呢?
points = [
[{ x: 6, y: 6 }, { x: 10, y: 6 }, { x: 10, y: 10 }, { x: 6, y: 10 }],
[{ x: 8, y: 6 }, { x: 10, y: 8 }, { x: 8, y: 10 }, { x: 6, y: 8 }]
]
let results = polygonClipping.xor(...poly)
let min_x = 100, min_y = 100
results.forEach(function (sub_results) {
sub_results.forEach(function (temps) {
temps.forEach(function (point) {
if (point[0] 0]
if (point[1] 1]
})
})
})
results.forEach(function (sub_results) {
sub_results.forEach(function (temps) {
temps.forEach(function (point) {
point[0] -= min_x
point[1] -= min_y
})
})
})
}
let result = this.polygonXor(points)
[
[[[0
, 0], [2, 0], [0, 2], [0, 0]]],
[[[0, 2], [2, 4], [0, 4], [0, 2]]],
[[[2, 0], [4, 0], [4, 2], [2, 0]]],
[[[2, 4], [4, 2], [4, 4], [2, 4]]]
]
同理计算出玩家操作图形的
xor
结果进行比对即可得出答案正确与否。
需要注意的是,获取玩家的
xor
结果并不能直接拿来与目标图形
xor
结果进行比较。
我们需要将
xor
的结果以左上角为参考点将图形平移至原点内,然后再进行比较,如果结果一致,则代表玩家答案正确。
在看本章节内容之前,建议先浏览一遍排行榜相关的官方文档:
好友排行榜、关系链数据,以便对相关内容有个大概的了解。
开放数据域
是一个封闭、独立的
JavaScript
作用域。
要让代码运行在开放数据域,需要在
game.json
中添加配置项
openDataContext
指定开放数据域的代码目录。
添加该配置项表示小游戏启用了开放数据域,这将会导致一些限制。
{
"openDataContext": "src/myOpenDataContext"
}
-
在游戏内使用
wx.setUserCloudStorage(obj)
对玩家游戏数据进行托管。
-
在开放数据域内使用
wx.getFriendCloudStorage(obj)
拉取当前用户所有同玩好友的托管数据
-
如果想要展示通过关系链
API
获取到的用户数据,如绘制排行榜等业务场景,需要将排行榜绘制到
sharedCanvas
上,再在主域将
sharedCanvas
渲染上屏。
let sharedCanvas = wx.getSharedCanvas()
function drawRankList (data) {
data.forEach((item, index) => {
})
}
wx.getFriendCloudStorage({
success: res => {
let data = res.data
drawRankList(data)
}
})
sharedCanvas
是主域和开放数据域都可以访问的一个离屏画布。在开放数据域调用
wx.getSharedCanvas()
将返回
sharedCanvas
。
let sharedCanvas = wx.getSharedCanvas()
let context = sharedCanvas.getContext('2d')
context.fillStyle = 'red'
context.fillRect(0, 0, 100, 100)
在主域中可以通过开放数据域实例访问
sharedCanvas
,通过
drawImage()
方法可以将
sharedCanvas
绘制到上屏画布。
let sharedCanvas = wx.getSharedCanvas()
let context = sharedCanvas.getContext('2d')
context.fillStyle = 'red'
context.fillRect(0, 0, 100, 100)
sharedCanvas
本质上也是一个离屏
Canvas
,而重设
Canvas
的宽高会清空
Canvas
上的内容。所以要通知开放数据域去重绘
sharedCanvas
。
openDataContext.postMessage({
command: 'render'
})
openDataContext.onMessage(data => {
if