凹语言版贪吃蛇
- 时间:2023-01-03
- 撰稿:凹语言开发组
- 转载请注明原文链接:https://wa-lang.org/smalltalk/st0018.html
贪吃蛇作为一个诞生于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
定义如下:
#画布对象
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_buf
,frame_buf
是一个动态数组,其中按行主序保存着画布每个像素的颜色值(颜色值为8位RGBA格式,每个像素占用4字节,即1个32位无符号整型数u32)。
NewCanvas
函数用于创建并初始化一个Canvas
对象:
#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
对象的下列方法读写画布帧缓存:
#获取画布对象坐标为(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: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
对象定义如下:
#画布事件回调函数原型
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_id
和Canvas.device_id
确定。canvas包的包级变量canvas_events
是一个动态数组,凹语言侧代码可以通过AttachCanvasEvents
函数将一个事件对象附加到事件对象数组中:
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
数组,调用与该画布关联的鼠标点击回调函数:
/*
供外部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
实现,它的定义如下
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)
}
GameState
的ca
属性类型为Canvas
,用于输出图形结果;grid
是以行主序保存的棋盘格状态;body
动态数组记录了贪吃蛇身体的每个节点的棋盘格坐标。由于1个像素在屏幕上非常小难以看清,因此1个棋盘格实际对应画布上一个10像素*10像素的正方形区域。GameState.Init
方法除了初始化上述属性,还通过canvas.AttachCanvasEvents
方法挂接了相应的交互事件回调函数,特别需要注意的是,该处挂接的OnKeyDown
事件是一个对象方法,它本质上是一个闭包。
游戏的处理流程很简单:
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中:
(() => {
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环境如何互相调用及传递数据。该例子体现了凹语言用于更复杂应用的开发潜力!