Skip to content
On this page

凹语言版贪吃蛇


贪吃蛇作为一个诞生于1976年的游戏,虽然逻辑非常简单,但使用凹语言实现它可以展示凹语言的包管理、与宿主JS环境交互、操作图形界面等能力。贪吃蛇游戏网页地址:https://wa-lang.org/wa/snake。运行结果如图:

  • 安装凹语言版本:go install wa-lang.org/wa@v0.4.1

模块分解

凹语言实现的贪吃蛇主要由以下三个模块组成:

  • canvas包,用凹语言编写,负责在凹语言侧管理画布对象,以及处理画布对象的交互操作;
  • 贪吃蛇主逻辑,用凹语言编写;
  • 页面环境,负责画布对象在JS侧的具体实现、JS方法向凹语言侧的导入,以及网页布局和消息循环。

代码:https://gitee.com/wa-lang/wa/tree/master/_examples/snake

canvas包

canvas包是凹语言侧操作页面画布对象的接口。画布对象Canvas定义如下:

wa
#画布对象
type Canvas struct {
    device_id: u32   //画布对象对应的网页DOM对象id
    width:     u32   //画布宽度,以像素为单位
    height:    u32   //画布高度,以像素为单位
    frame_buf: []u32 //画布帧缓存,容量为Width * Height
}

Canvas对应于网页中的画布DOM对象,页面中的一块可逐像素操作的矩形区域。由于一个应用可能创建多个画布,因此Canvas对象中有一个device_id属性用于区别不同的画布。除宽度width、高度height属性外,Canvas最重要的属性是它的帧缓存frame_bufframe_buf是一个动态数组,其中按行主序保存着画布每个像素的颜色值(颜色值为8位RGBA格式,每个像素占用4字节,即1个32位无符号整型数u32)。

NewCanvas函数用于创建并初始化一个Canvas对象:

wa
#wa:import wa_js_env newCanvas
fn newCanvas_JS(w, h: u32) => u32

#创建一个宽度为w像素、高度为h像素的画布对象
fn NewCanvas(w, h: u32) => *Canvas {
    var canvas Canvas
	canvas.device_id = newCanvas_JS(w, h)
	canvas.width = w
	canvas.height = h
	canvas.frame_buf = make([]u32, w * h)

	return &canvas
}

由于Wasm不能直接操作网页中的DOM对象,因此NewCanvas函数需要调用由JS宿主环境导入的newCanvas_JS函数方可完成画布DOM对象的创建。编译标签#wa:import wa_js_env newCanvas标明了后续的newCanvas_JS是由外部导入的以及对应的导入路径,因此它只定义了原型而没有函数体。画布DOM对象创建后,代码make([]u32, w * h)创建了对应宽高的帧缓存,并执行了其他一些初始化操作。

凹语言侧代码可通过Canvas对象的下列方法读写画布帧缓存:

wa
#获取画布对象坐标为(x, y)处的像素颜色值
fn Canvas.GetPixel(x, y: u32) => u32 {
    return this.frame_buf[y * this.width + x]
}

#设置画布对象坐标(x, y)处的颜色值为color
fn Canvas.SetPixel(x, y, color: u32) {
    this.frame_buf[y * this.width + x] = color
}

当整个帧缓存填充完毕后,通过Canvas.Flush方法将帧缓存数据更新至页面中的画布对象;与创建画布DOM对象类似,该操作也需要通过JS环境导入的函数完成:

wa
#wa:import wa_js_env updateCanvas
fn updateCanvas_JS(id: u32, buf: *u32)

fn Canvas.Flush() {
    updateCanvas_JS(this.device_id, &this.frame_buf[0])
}

除了Canvas对象外,canvas包还需要处理页面画布DOM对象上的交互事件(如键盘按键、鼠标点击等),否则用户无法操作贪吃蛇走向希望的方向。与此相关的CanvasEvents对象定义如下:

wa
#画布事件回调函数原型
type OnTouch fn (x, y: u32)
type OnKey fn(key: u32)

#画布事件
type CanvasEvents struct {
	Device_id:   u32     //画布设备ID
    OnMouseDown: OnTouch //鼠标按下时的回调处理函数
    OnMouseUp:   OnTouch //鼠标松开时的回调处理函数
	OnKeyDown:   OnKey   //键盘按下时的回调处理函数
	OnKeyUp:     OnKey   //键盘弹起时的回调处理函数
}

var canvas_events: []CanvasEvents

一个CanvasEvents对应某个Canvas的一组交互事件回调函数,其对应关系由CanvasEvents.Device_idCanvas.device_id确定。canvas包的包级变量canvas_events是一个动态数组,凹语言侧代码可以通过AttachCanvasEvents函数将一个事件对象附加到事件对象数组中:

wa
fn AttachCanvasEvents(e: CanvasEvents) {
	for i := range canvas_events {
		if canvas_events[i].Device_id == e.Device_id {
			canvas_events[i] = e
			return
		}
	}

	canvas_events = append(canvas_events, e)
}

当某个画布DOM对象上产生鼠标点击事件时,即可通过遍历canvas_events数组,调用与该画布关联的鼠标点击回调函数:

wa
/*
供外部JS调用的鼠标按下事件响应函数
id为画布DOM对象对应的Canvas对象id
(x, y)为画布像素坐标系坐标
*/
fn OnMouseDown(id: u32, x, y:u32) {
    for _, i := range canvas_events {
		if i.Device_id == id {
			i.OnMouseDown(x, y)
			return
		}
	}
}

游戏主逻辑

游戏主逻辑由GameState对象的全局实例gameState实现,它的定义如下

wa
type GameState struct {
	w, h  :i32
	scale :i32
	grid  :[]i8
	body  :[]Position
	dir   :i32

	ca :*canvas.Canvas
}

var gameState: GameState

fn GameState.Init(w, h: i32, scale: i32) {
	this.w = w
	this.h = h
	this.scale = scale
	this.grid = make([]i8, u32(w*h))
	this.ca = canvas.NewCanvas(u32(w*scale), u32(h*scale))

	var caev: canvas.CanvasEvents
	caev.Device_id = this.ca.GetDeviceID()
	caev.OnMouseDown = fn(x, y: u32) {}
	caev.OnMouseUp = fn(x, y: u32) {}
	caev.OnKeyUp = fn(key: u32) {}
	caev.OnKeyDown = this.OnKeyDown

	Dirs[DirNull] = Position{x: 0, y: 0}
	Dirs[DirLeft] = Position{x: -1, y: 0}
	Dirs[DirUp] = Position{x: 0, y: -1}
	Dirs[DirRight] = Position{x: 1, y: 0}
	Dirs[DirDown] = Position{x: 0, y: 1}

	canvas.AttachCanvasEvents(caev)
}

GameStateca属性类型为Canvas,用于输出图形结果;grid是以行主序保存的棋盘格状态;body动态数组记录了贪吃蛇身体的每个节点的棋盘格坐标。由于1个像素在屏幕上非常小难以看清,因此1个棋盘格实际对应画布上一个10像素*10像素的正方形区域。GameState.Init方法除了初始化上述属性,还通过canvas.AttachCanvasEvents方法挂接了相应的交互事件回调函数,特别需要注意的是,该处挂接的OnKeyDown事件是一个对象方法,它本质上是一个闭包。

游戏的处理流程很简单:

wa
fn GameState.Step() {
	if this.dir == DirNull {
		return
	}

	newHead := this.body[len(this.body)-1]
	newHead.x += Dirs[this.dir].x
	newHead.y += Dirs[this.dir].y

	if newHead.x < 0 {
		newHead.x = this.w - 1
	} else if newHead.x >= this.w {
		newHead.x = 0
	}
	if newHead.y < 0 {
		newHead.y = this.h - 1
	} else if newHead.y >= this.h {
		newHead.y = 0
	}

	switch this.grid[newHead.y*this.w+newHead.x] {
	case GridBody:
		this.Start()
		return

	case GridFood:
		this.SetGridType(newHead, GridBody)
		this.body = append(this.body, newHead)
		this.GenFood()

	default:
		this.SetGridType(newHead, GridBody)
		this.SetGridType(this.body[0], GridNull)
		this.body = append(this.body, newHead)
		this.body = this.body[1:]
	}

	this.ca.Flush()
}

fn Step() {
	gameState.Step()
}

fn main() {
	gameState.Init(32, 32, 10)
	gameState.Start()
}

既按行进方向移动贪吃蛇的身体,并判断是否吃到自己或食物。Step函数导出到外部JS环境,是消息循环入口。

页面环境

页面环境的主要运行逻辑位于wa_app.js中:

js
(() => {
  class WaApp {
    //..
    init(url) {
      let app = this;
      let importsObject = {
        wa_js_env: new function () {
          this.newCanvas = (w, h) => {
            let canvas = document.createElement('canvas');
			//...
          }
          this.updateCanvas = (id, block, data) => {
            let img = this._ctx.createImageData(this._canvas.width, this._canvas.height);
            let buf_len = this._canvas.width * this._canvas.height * 4
            let buf = app.memUint8Array(data, buf_len);
            for (var i = 0; i < buf_len; i++) {
              img.data[i] = buf[i];
            }
            this._ctx.putImageData(img, 0, 0);
          }
        }
      }
      WebAssembly.instantiateStreaming(fetch(url), importsObject).then(res => {
        this._inst = res.instance;
        this._inst.exports._start();
      })
    }

    mem() {
      return this._inst.exports.memory;
    }

    memView(addr, len) {
      return new DataView(this._inst.exports.memory.buffer, addr, len);
    }

    memUint8Array(addr, len) {
      return new Uint8Array(this.mem().buffer, addr, len)
    }
	//..
  }

  function gameLoop() {
    window['waApp']._inst.exports['snake.Step']();
  }

  window['waApp'] = new WaApp();
  window['waApp'].init("./snake.wasm")
  const timer = setInterval(gameLoop, IS_MOBILE ? 150 : 100);
})()

它使用WebAssembly.instantiateStreaming方法创建了贪吃蛇的Wasm实例,并通过导入对象将newCanvas/updateCanvas等方法注入实例供凹语言侧调用;并周期性的调用导出的snake.Step方法驱动游戏进程。

小结

贪吃蛇例子较为完整的展示了如何使用凹语言开发网页应用。其中使用了动态数组、方法值闭包、自定义对象等特性,演示了凹语言与JS环境如何互相调用及传递数据。该例子体现了凹语言用于更复杂应用的开发潜力!