专栏名称: SegmentFault思否
SegmentFault (www.sf.gg)开发者社区,是中国年轻开发者喜爱的极客社区,我们为开发者提供最纯粹的技术交流和分享平台。
目录
相关文章推荐
程序员的那些事  ·  印度把 DeepSeek ... ·  2 天前  
程序猿  ·  “我真的受够了Ubuntu!” ·  2 天前  
程序猿  ·  “未来 3 年内,Python 在 AI ... ·  3 天前  
程序员的那些事  ·  成人玩偶 + ... ·  4 天前  
程序员小灰  ·  DeepSeek做AI代写,彻底爆了! ·  4 天前  
51好读  ›  专栏  ›  SegmentFault思否

烧脑!JS+Canvas 带你体验「偶消奇不消」的智商挑战

SegmentFault思否  · 公众号  · 程序员  · 2019-09-18 12:11

正文

本文原载于 SegmentFault 专栏 一个会小程序开发的iOSer
作者:huangjianke
整理编辑:SegmentFault



启逻辑之高妙,因想象而自由
层叠拼图Plus 是一款需要空间想象力和逻辑推理能力完美结合的微信小游戏。
偶消奇不消,在简单的游戏规则下却有着无数种可能性,需要你充分发挥想象力去探索。
看似简单却具有极大的挑战性和趣味性,这就是其魅力所在!温馨提示,体验后再阅读此文体验更佳哦!
预览:


Talk is cheap,Show me the code.
层叠拼图Plus 微信小游戏采用 js + canvas 实现,没有使用任何游戏引擎,对于初学者来说,也比较容易入门。
接下来,我将通过以下几个点循序渐进的讲解 层叠拼图Plus 微信小游戏的实现。
  • 如何解决 Canvas 绘图模糊?
  • 如何绘制任意多边形图形?
  • 1 + 1 = 0,「偶消奇不消」的效果如何实现?
  • 如何判断一个点是否在任意多边形内部 ?
  • 如何判断游戏结果是否正确?
  • 排行榜的展示
  • 游戏性能优化


如何解决 Canvas 绘图模糊?

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,「偶消奇不消」的效果如何实现?

1 + 1 = 0,是层叠拼图Plus小游戏玩法的精髓所在。



有经验的同学,也许一眼就发现了, 1 + 1 = 0 刚好符合通过 异或运算 得出的结果。
当然,细心的同学也可能已经发现,在 如何绘制任意多边形图形 这一章节内,有一句特殊的代码: this.ctx.globalCompositeOperation = 'xor' ,也正是通过设置 CanvasContext globalCompositeOperation 属性值为 xor 便实现了「偶消奇不消」的神奇效果。


globalCompositeOperation 是指 在绘制新形状时应用的合成操作的类型

如何判断一个点是否在任意多边形内部?

当回转数为 0 时,点在闭合曲线外部。


讲到这里,我们已经知道如何在 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]
            })
        })
    })

    // 以左上角为参考点 多边形平移至 原点 {x: 0, y: 0}
    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)
xor 结果:
[
    [[[0 0], [20], [02], [00]]],
    [[[02], [24], [04], [02]]],
    [[[20], [40], [42], [20]]],
    [[[24], [42], [44], [24]]]
]
同理计算出玩家操作图形的 xor 结果进行比对即可得出答案正确与否。
需要注意的是,获取玩家的 xor 结果并不能直接拿来与目标图形 xor 结果进行比较。
我们需要将 xor 的结果以左上角为参考点将图形平移至原点内,然后再进行比较,如果结果一致,则代表玩家答案正确。

排行榜的展示

有人的地方就有江湖,有江湖的地方就有排行

在看本章节内容之前,建议先浏览一遍排行榜相关的官方文档: 好友排行榜、关系链数据,以便对相关内容有个大概的了解。

  • 开放数据域
开放数据域 是一个封闭、独立的 JavaScript 作用域。
要让代码运行在开放数据域,需要在 game.json 中添加配置项 openDataContext 指定开放数据域的代码目录。
添加该配置项表示小游戏启用了开放数据域,这将会导致一些限制。
// game.json
{
  "openDataContext""src/myOpenDataContext"
}

  • 在游戏内使用 wx.setUserCloudStorage(obj) 对玩家游戏数据进行托管。
  • 在开放数据域内使用 wx.getFriendCloudStorage(obj) 拉取当前用户所有同玩好友的托管数据
  • 展示关系链数据
如果想要展示通过关系链 API 获取到的用户数据,如绘制排行榜等业务场景,需要将排行榜绘制到 sharedCanvas 上,再在主域将 sharedCanvas 渲染上屏。


// src/myOpenDataContext/index.js
let sharedCanvas = wx.getSharedCanvas()

function drawRankList (data{
  data.forEach((item, index) => {
    // ...
  })
}

wx.getFriendCloudStorage({
  successres => {
    let data = res.data
    drawRankList(data)
  }
})
sharedCanvas 是主域和开放数据域都可以访问的一个离屏画布。在开放数据域调用 wx.getSharedCanvas() 将返回 sharedCanvas
// src/myOpenDataContext/index.js
let sharedCanvas = wx.getSharedCanvas()
let context = sharedCanvas.getContext('2d')
context.fillStyle = 'red'
context.fillRect(00100100)
在主域中可以通过开放数据域实例访问 sharedCanvas ,通过 drawImage() 方法可以将 sharedCanvas 绘制到上屏画布。
// src/myOpenDataContext/index.js
let sharedCanvas = wx.getSharedCanvas()
let context = sharedCanvas.getContext('2d')
context.fillStyle = 'red'
context.fillRect(00100100)


sharedCanvas 本质上也是一个离屏 Canvas ,而重设 Canvas 的宽高会清空 Canvas 上的内容。所以要通知开放数据域去重绘 sharedCanvas

// game.js
openDataContext.postMessage({
  command'render'
})

// src/myOpenDataContext/index.js
openDataContext.onMessage(data => {
  if






请到「今天看啥」查看全文