制作简单游戏:第三部分
游戏脚本与输入
game.js 与 input.js 两个脚本都挂在场景名为 Game 的根实体上。脚本通常按层级遍历顺序执行,因此将不依赖特定实体的脚本挂在第一个实体上最方便。也可在编辑器 Settings 面板中管理脚本加载顺序,无需挂载到实体就能优先加载脚本。
game.js
- ESM
- 传统脚本
import { Script, RESOLUTION_AUTO, FILLMODE_FILL_WINDOW } from 'playcanvas';
export class Game extends Script {
static scriptName = "game";
/**
*
* @attribute
* @title UI Menu
* @type {Entity}
*/
uiMenu = null;
/**
*
* @attribute
* @title UI In Game
* @type {Entity}
*/
uiInGame = null;
/**
*
* @attribute
* @title UI Game Over
* @type {Entity}
*/
uiGameOver = null;
/**
*
* @attribute
* @title Audio
* @type {Entity}
*/
audio = null;
static STATE_MENU = 'menu';
static STATE_INGAME = 'ingame';
static STATE_GAMEOVER = 'gameover';
// initialize code called once per entity
initialize() {
this._state = Game.STATE_MENU;
this._score = 0;
this.setResolution();
window.addEventListener("resize", this.setResolution.bind(this));
// listen to events from the UI
this.app.on("ui:start", this.start, this);
this.app.on("ui:reset", this.reset, this);
}
setResolution() {
// Adjust resolution based on screen size
const w = window.screen.width;
const h = window.screen.height;
if (w < 640) {
this.app.setCanvasResolution(RESOLUTION_AUTO, w, h);
this.app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
}
}
// Call this to move from MENU to INGAME
start() {
this._state = Game.STATE_INGAME;
this.app.fire("game:start");
this.uiMenu.enabled = false;
this.uiInGame.enabled = true;
this.audio.sound.play("music");
}
// Call this to move from INGAME to GAMEOVER
gameOver() {
this._state = Game.STATE_GAMEOVER;
this.app.fire("game:gameover");
this.uiInGame.enabled = false;
this.uiGameOver.enabled = true;
this.audio.sound.stop();
this.audio.sound.play("gameover");
}
// Call this to move from GAMEOVER to MENU
reset() {
this.app.fire("game:reset");
this.resetScore();
this._state = Game.STATE_MENU;
this.uiGameOver.enabled = false;
this.uiMenu.enabled = true;
this.audio.sound.stop();
}
// return the current score
getScore() {
return this._score;
}
// add value to the score
addScore(v) {
this._score += v;
this.app.fire("game:score", this._score);
}
// reset the score
resetScore() {
this._score = 0;
this.app.fire("game:score", this._score);
}
}
var Game = pc.createScript('game');
Game.attributes.add('uiMenu', {type: 'entity'});
Game.attributes.add('uiInGame', {type: 'entity'});
Game.attributes.add('uiGameOver', {type: 'entity'});
Game.attributes.add('audio', {type: 'entity'});
Game.STATE_MENU = 'menu';
Game.STATE_INGAME = 'ingame';
Game.STATE_GAMEOVER = 'gameover';
// initialize code called once per entity
Game.prototype.initialize = function() {
this._state = Game.STATE_MENU;
this._score = 0;
this.setResolution();
window.addEventListener("resize", this.setResolution.bind(this));
// listen to events from the UI
this.app.on("ui:start", this.start, this);
this.app.on("ui:reset", this.reset, this);
};
Game.prototype.setResolution = function () {
// if the screen width is less than 640
// fill the whole window
// otherwise
// use the default setting
var w = window.screen.width;
var h = window.screen.height;
if (w < 640) {
this.app.setCanvasResolution(pc.RESOLUTION_AUTO, w, h);
this.app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
}
};
// Call this to move from MENU to INGAME
Game.prototype.start = function () {
this._state = Game.STATE_INGAME;
this.app.fire("game:start");
this.uiMenu.enabled = false;
this.uiInGame.enabled = true;
this.audio.sound.play("music");
};
// Call this to move from INGAME to GAMEOVER
Game.prototype.gameOver = function () {
this._state = Game.STATE_GAMEOVER;
this.app.fire("game:gameover");
this.uiInGame.enabled = false;
this.uiGameOver.enabled = true;
this.audio.sound.stop();
this.audio.sound.play("gameover");
};
// Call this to move from GAMEOVER to MENU
Game.prototype.reset = function () {
this.app.fire("game:reset");
this.resetScore();
this._state = Game.STATE_MENU;
this.uiGameOver.enabled = false;
this.uiMenu.enabled = true;
this.audio.sound.stop();
};
// return the current score
Game.prototype.getScore = function () {
return this._score;
};
// add a value to the score
Game.prototype.addScore = function (v) {
this._score += v;
this.app.fire("game:score", this._score);
};
// reset the score
Game.prototype.resetScore = function () {
this._score = 0;
this.app.fire("game:score", this._score);
};
游戏状态
游戏脚本管理整体状态,公开修改状态的方法,并通过事件通知其他代码状态已变化。
我们将游戏分为 Menu(菜单)、In Game(游戏中)和 Game Over(游戏结束)三个主要状态,分别通过 start()、gameOver()、reset() 进行切换。这些方法会设置 _state 记录当前状态,触发应用事件通知其他脚本,切换界面元素的显示,并管理音乐或游戏结束音效。
其他脚本会在适当时机调用状态切换方法。例如,足球从屏幕底部掉出时,ball.js 调用 gameOver()。
应用事件
先看看游戏脚本如何在应用对象上触发事件。
this.app.fire("game:start")
事件非常适合从一个脚本向多个脚本通信。某个对象,这里是 this.app,选择触发事件;其他能访问该对象的代码可以监听其中一个或多个事件,在事件触发时收到通知。
其中一个限制是监听方必须能访问事件对象。这也正是应用事件实用的原因:每个 PlayCanvas 脚本都能访问 this.app,因此它适合作为脚本间的中央通信枢纽。
我们采用命名空间模式,让事件含义更清楚并避免冲突。监听上述 game:start 事件的代码如下:
this.app.on("game:start", function () {
console.log("game:start event was fired");
}, this)
计分
游戏脚本还管理当前分数,公开修改分数的方法,并通过事件通知其他代码分数变化。
分辨率
最后,游戏脚本负责初始分辨率,确保主画布在移动端与桌面端尺寸正确。屏幕宽度小于 640 像素时视为移动端,游戏填满整个屏幕;桌面端则使用项目设置中的预设分辨率。
input.js
输入脚本监听鼠标和触摸屏,将两者统一为通用的 tap(点击)操作,并通知应用其他部分。
- ESM
- 传统脚本
import { Script, Vec3 } from 'playcanvas';
export class Input extends Script {
static scriptName = "input";
/**
* @attribute
* @title Ball
* @type {Entity}
*/
ball = null;
/**
* @attribute
* @title Camera
* @type {Entity}
*/
camera = null;
/**
* @attribute
* @title Ball Radius
* @type {number}
*/
ballRadius = 0.5;
static worldPos = new Vec3();
// initialize code called once per entity
initialize() {
this._paused = true;
// Listen for game events so we know whether to respond to input
this.app.on("game:start", () => {
this._paused = false;
});
this.app.on("game:gameover", () => {
this._paused = true;
});
// set up touch events if available
if (this.app.touch) {
this.app.touch.on("touchstart", this._onTouchStart, this);
}
// set up mouse events
this.app.mouse.on("mousedown", this._onMouseDown, this);
}
_onTap(x, y) {
let p = this.ball.getPosition();
let camPos = this.camera.getPosition();
let worldPos = Input.worldPos;
// Get the position in the 3D world of the touch or click
this.camera.camera.screenToWorld(x, y, camPos.z - p.z, worldPos);
// get the distance of the touch/click to the ball
let dx = (p.x - worldPos.x);
let dy = (p.y - worldPos.y);
// If the click is inside the ball, tap the ball
let lenSqr = dx * dx + dy * dy;
if (lenSqr < this.ballRadius * this.ballRadius) {
this.ball.script.ball.tap(dx, dy);
}
}
_onTouchStart(e) {
if (this._paused) {
return;
}
// respond to event
let touch = e.changedTouches[0];
this._onTap(touch.x, touch.y);
// stop mouse events firing as well
e.event.preventDefault();
}
_onMouseDown(e) {
if (this._paused) {
return;
}
// respond to event
this._onTap(e.x, e.y);
}
}
var Input = pc.createScript('input');
Input.attributes.add('ball', {type: 'entity'});
Input.attributes.add('camera', {type: 'entity'});
Input.attributes.add('ballRadius', {type: 'number', default: 0.5});
Input.worldPos = new pc.Vec3();
// initialize code called once per entity
Input.prototype.initialize = function() {
var self = this;
this._paused = true;
// Listen for game events so we know whether to respond to input
this.app.on("game:start", function () {
self._paused = false;
});
this.app.on("game:gameover", function () {
self._paused = true;
});
// set up touch events if available
if (this.app.touch) {
this.app.touch.on("touchstart", this._onTouchStart, this);
}
// set up mouse events
this.app.mouse.on("mousedown", this._onMouseDown, this);
};
Input.prototype._onTap = function (x, y) {
var p = this.ball.getPosition();
var camPos = this.camera.getPosition();
var worldPos = Input.worldPos;
// Get the position in the 3D world of the touch or click
// Store the in worldPos variable.
// This position is at the same distance away from the camera as the ball
this.camera.camera.screenToWorld(x, y, camPos.z - p.z, worldPos);
// get the distance of the touch/click to the ball
var dx = (p.x - worldPos.x);
var dy = (p.y - worldPos.y);
// If the click is inside the ball, tap the ball
var lenSqr = dx*dx + dy*dy;
if (lenSqr < this.ballRadius*this.ballRadius) {
this.ball.script.ball.tap(dx, dy);
}
};
Input.prototype._onTouchStart = function (e) {
if (this._paused) {
return;
}
// respond to event
var touch = e.changedTouches[0];
this._onTap(touch.x, touch.y);
// stop mouse events firing as well
e.event.preventDefault();
};
Input.prototype._onMouseDown = function (e) {
if (this._paused) {
return;
}
// respond to event
this._onTap(e.x, e.y);
};
首先在 initialize 中设置事件监听。监听应用事件来判断游戏是否暂停,即是否处于菜单或游戏结束状态。暂停时不响应点击。随后监听触摸与鼠标事件,注意必须先检查 this.app.touch 是否存在。
触摸事件
触摸事件取第一个触点并传递其屏幕坐标,同时在浏览器事件上调用 preventDefault(),防止浏览器额外生成 click 事件。
鼠标事件
对于 mousedown 事件,将屏幕坐标传给点击处理代码。PlayCanvas 保证触摸与鼠标事件使用同一坐标系,普通浏览器事件则不一定如此。
点击处理
_onTap() 接收屏幕坐标 (x, y),判断是否命中足球。如果命中,就通知足球脚本。
this.camera.camera.screenToWorld(x, y, camPos.z - p.z, worldPos);
具体而言,函数请求相机将屏幕坐标 (x, y) 转为对应的三维空间位置。这需要提供深度,即目标三维点距离屏幕多远。本例使用足球所在的深度。
我们还传入向量 Input.worldPos。PlayCanvas 应用应避免在更新循环中不断创建对象,例如通过 new pc.Vec3() 创建向量。使用 new 分配的内存越多,浏览器需要执行的垃圾回收就越多。垃圾回收相对较慢,频繁发生会让游戏或应用卡顿。
多数情况下,PlayCanvas 允许传入向量等对象,使你可以预先分配并重复使用。
// get the distance of the touch/click to the ball
var dx = (p.x - worldPos.x);
var dy = (p.y - worldPos.y);
// If the click is inside the ball, tap the ball
var lenSqr = dx*dx + dy*dy;
if (lenSqr < this.ballRadius*this.ballRadius) {
this.ball.script.ball.tap(dx, dy);
}
得到点击处的三维点后,检查其是否与足球重叠。这里比较半径平方与点击点到球心距离的平方,避免每次检测执行较慢的平方根运算。
如果命中足球,就调用足球脚本的 tap(dx, dy),传入点击位置相对球心的偏移。第四部分将使用这些数值。