跳到主要内容

第一个游戏

作为前端开发,我们做 Tinoe 的初衷是希望降低前端同学入门 web3D 的门槛,我们希望 Tinoe 能够解放前端同学的生产力,更少被概念和 API 束缚,更多关注逻辑,用更多的创意去创造更美好的互动世界。

在这里,我们尝试带大家用一百多行代码来搞定一个经典小游戏:飞机大战。

游戏分析

玩法

飞机大战的核心玩法其实很简单,操纵主角躲避敌方飞机和敌机发射的子弹,利用自动发射的子弹消灭敌机。

元素及逻辑

  • 主角:自动发射子弹、拖拽移动、碰撞到敌机或者敌机子弹死亡
  • 敌机:自动发射子弹、随机移动、随机出现、被主角子弹击中死亡
  • 主角子弹:固定速率移动、击中敌机消亡
  • 敌机子弹:固定速率移动、击中主角消亡

主角 敌机 子弹1 子弹2

代码实现

Tinoe 同时提供了 3D 渲染和 2D 渲染的能力,目前更推荐使用 3D 渲染,这一部分能力经过业务验证目前相对更稳定,基于此我们开始代码编写部分。

资源加载

👆 的几张图是我们在游戏运行前需要提前加载下来作为纹理的,Tinoe 的纹理加载器是作为插件提供的,类似相对独立的功能(比如物理引擎、模型加载器等)我们都放在了我们的插件库@tinoe/glk中单独打包,你可以按需使用:

import { Texture2DLoader } from '@tinoe/glk';

const loadResources = async () => {
return await Texture2DLoader.loadAll([
'https://lf-tinoe.doubao.com/obj/tinoe/marvel/actor.png',
'https://lf-tinoe.doubao.com/obj/tinoe/marvel/fire.png',
'https://lf-tinoe.doubao.com/obj/tinoe/marvel/enemy.png',
'https://lf-tinoe.doubao.com/obj/tinoe/marvel/efire.png',
]);
};
  • 我们推荐使用loadAll 方法进行并行资源加载

舞台和场景

资源加载完后进行场景和舞台的搭建:

import { Stage, Scene, OrthographicCamera, AmbientLight } from 'tinoe';

const initScene = () => {
const stage = new Stage({ canvas });
const scene = new Scene();

const camera = new OrthographicCamera({
position: [0, 0, -2]
top: 3,
bottom: -3,
left: -3,
right: 3,
});

const ambientLight = new AmbientLight({ color: '#fff' });
scene.lightManager.addLights(ambientLight);
}
  • 舞台是我们一切的开始,初始化舞台时我们推荐传入 canvas 实例,便于后续场景的控制
  • 当你不知道相机怎么摆时,你可以咨询美术同学,或者直接像我们这里一样放在正对-z 方向,这里我们不需要透视效果,所以我们选择了正交相机,做一个 2D 的效果
  • 我们需要至少为场景设置环境光让场景亮起来,大部分互动小游戏场景下这可能就足够了

主角

创建主角

const aircraft = new SpriteMesh({ texture: aircraftTex });
const collider = new Collider(aircraft, { type: ColliderType.BOX });
scene.addChildren(aircraft);
scene.colliderManager.addCollider(collider);
  • 3D 场景下的伪 2D 渲染我们推荐使用SpriteMesh,因为它足够简单,且能满足大部分场景
  • 为了实现碰撞效果,我们需要给主角添加碰撞体,这里我们加一个简单的 Box,并添加到场景的碰撞管理器中,Tinoe 内部会在每一帧去遍历碰撞体并进行碰撞检测,自动触发用户注册的碰撞回调
  • 关于 Tinode 一帧内做的事情参见文末的补充 1 部分 👇

主角脚本

让我们开始主角逻辑脚本实现:

import { Script } from 'tinoe';

class AircraftScript extends Script {
rate = 0;
fire() {
const fire = new SpriteMesh({ texture: fireTex });
fire.name = 'fire';
fire.scale.set(0.4, 0.4, 1);
scene.addChildren(fire);
// 子弹的逻辑由子弹脚本完成
scene.scriptManager.addScript(FireScript, fire);
// 给子弹添加碰撞体
const collider = new Collider(fire, { type: ColliderType.BOX });
scene.colliderManager.addCollider(collider);
}
onAwake(): void {
// 设置主角出生位置
this.instance.position.set(0, 0, 0);
this.instance.scale.set(0.6, 0.6, 1);
}
onUpdate(dt: number): void {
// rate控制子弹发射速度,每{rate}帧发射一次
if (this.rate < 5) {
this.rate++;
} else {
this.rate = 0;
this.fire();
}
}
onPointerDrag(ev: TinoeEvent): void {
if (ev.data) {
const { x, y } = ev.data[0].position;
// 这里做的实际上是一个简单的画布坐标->世界坐标的映射
this.instance.position.x = 3 - (x / stage.canvas.width) * 6;
this.instance.position.y = 3 - (y / stage.canvas.height) * 6;
}
}
onCollisionEnter(ev: TinoeEvent<Mesh>): void {
if (ev.target.name === 'enemy' || ev.target.name === 'efire') {
console.log('僵尸吃掉了你的脑子');
}
}
}
  • Tinoe 提供了Script类方便用户逻辑编写,用户可以实现 Tinoe 暴露出的对应生命周期、交互、碰撞等钩子的处理函数,也可以任意扩展功能,比如这里实现的fire方法
  • 自动开火其实是按照一定速率生成新的子弹,子弹的移动和逻辑都在子弹的逻辑脚本中完成,参见onUpdate
  • 主角的移动是根据拖拽事件获取到的用户输入位置更新主角的位置,参见onPointerDrag
  • 主角碰撞上敌机或者敌机子弹这件事情在 Tinoe 的碰撞检测阶段完成,参见onCollisionEnter

脚本管理器

scene.scriptManager.addScript(AircraftScript, aircraft);
  • 最后,我们为主角编写的逻辑脚本需要通过scene.scriptManager.addScript完成添加。为了最低化前端同学理解成本,我们没有采用 ECS 架构,我们的部分功能扩展也没有放到实体,而是落到场景提供的管理器统一管理

至此,我们的主角逻辑就完成了,下一步整个敌机?

但是问题来了,敌机怎么生成,什么时候生成呢?回顾下我们的思路,我们会发现除了游戏元素本身逻辑需要通过脚本编写外,很多时候还需要一个针对整个游戏的脚本来做一些全局的逻辑,所以,让我们先整个GameScript

Game

飞机大战场景中,game 需要做的事情其实不多,这里我们先来做生成敌机这件事:

class GameScript extends Script<Mesh> {
enemyCount = 0;
addEnemy() {
const enemy = new SpriteMesh({ texture: enemyTex });
enemy.scale.set(0.4, 0.4, 1);
scene.addChildren(enemy);
// 敌机也是需要碰撞体的
scene.scriptManager.addScript(EnemyScript, enemy);
const collider = new Collider(enemy, { type: ColliderType.BOX });
scene.colliderManager.addCollider(collider);
}
onAwake() {
// 监听敌机死亡事件
stage.on('enemydie', () => {
this.enemyCount--;
});
}
onUpdate(dt: number): void {
// 简单的新增逻辑
if (this.enemyCount < 5) {
this.addEnemy();
this.enemyCount++;
}
}
}
scene.scriptManager.addScript(GameScript);
  • onAwake 常用于做一些初始化、监听等工作,整个场景生命周期只会执行一次
  • 互动游戏的场景,推荐使用EventEmitter通过发布订阅控制整体流程,Tinoe 的大部分元素包括 Scene 和 Stage 也都继承自EventEmitter

敌机

敌机核心逻辑包括开火、移动、被击毁:

class EnemyScript extends Script<Mesh> {
left = 1;
down = 1;
onAwake(): void {
// 出生位置x随机,y=3是屏幕最上
this.instance.name = 'enemy';
this.instance.position.y = 3;
this.instance.position.x = (Math.random() - 0.5) * 6;
}
rate = 0;
fire() {
const fire = new SpriteMesh({ texture: efireTex });
fire.name = 'efire';
fire.scale.set(0.2, 0.2, 1);
fire.position.set(this.instance.position.x, this.instance.position.y, 0);
scene.addChildren(fire);
scene.scriptManager.addScript(EFireScript, fire);
const collider = new Collider(fire, { type: ColliderType.BOX });
scene.colliderManager.addCollider(collider);
}
onUpdate(dt: number): void {
// 和主角不一样的发射速率
// 那必须慢点,不然谁玩得过,毕竟人力有时尽
if (this.rate < 30) {
this.rate++;
} else {
this.rate = 0;
this.fire();
}
if (this.instance.position.x < -3) this.left = 0;
if (this.instance.position.x > 3) this.left = 1;
if (this.instance.position.y < -3) this.down = 0;
if (this.instance.position.y > 3) this.down = 1;
this.left ? (this.instance.position.x -= dt) : (this.instance.position.x += dt);
this.down ? (this.instance.position.y -= dt) : (this.instance.position.y += dt);
}
onCollisionEnter(ev: TinoeEvent<Mesh>): void {
if (ev.target.name === 'fire') {
this.instance.disable();
ev.target.disable();
}
}
onDisable(): void {
scene.removeChildren(this.instance);
scene.colliderManager.removeColliderByNode(this.instance);
stage.dispatch('enemydie', {});
}
}
  • 碰撞和开火其实和主角很像了,需要注意的是这里通过调用 disable 触发了另一个onDisable的钩子,在这里我们做了物体以及对应的碰撞体资源的回收,游戏里寸金寸土,这只是极其简陋的优化了
  • onUpdate这里做了一个简单的移动轨迹,确保敌机在相机可视区内反复横跳

子弹

子弹的逻辑就比较简单了,包含移动、碰撞:

class FireScript extends Script<Mesh> {
onAwake(): void {
this.instance.position.set(aircraft.position.x, aircraft.position.y + 0.2, 0);
}
onUpdate(dt: number): void {
// 出屏幕了,回收掉
if (this.instance.position.y > 3) {
this.instance.disable();
} else {
this.instance.position.y += 0.05;
}
}
onCollisionEnter(ev: TinoeEvent<Mesh>): void {
// 碰撞到敌机和敌机子弹
if (ev.target.name === 'efire' || ev.target.name === 'enemy') {
ev.target.disable();
}
}
onDisable(): void {
scene.removeChildren(this.instance);
scene.colliderManager.removeColliderByNode(this.instance);
}
}

敌机子弹

敌机子弹,其实逻辑是一样的:

class EFireScript extends Script<Mesh> {
onUpdate(dt: number): void {
if (this.instance.position.y < -3) {
this.instance.disable();
} else {
this.instance.position.y -= 0.02;
}
}
onCollisionEnter(ev: TinoeEvent<Mesh>): void {
if (ev.target.name === 'fire') {
this.instance.disable();
ev.target.disable();
}
}
onDisable(): void {
scene.removeChildren(this.instance);
scene.colliderManager.removeColliderByNode(this.instance);
}
}

运行

最后的最后,运行场景:

stage.loop(scene);

是不是很简单,快来一起试试吧~

最终成果

更多

补充 1:Tinoe 的一个 Tick

图糙理不糙,Tinoe 一个 tick 内做的事情简化版

tick

  • Tinoe 的每个 Tick 会包含TickLogicTickRender这两部分,前者负责处理物理、交互输入、动画、用户对应生命周期逻辑等数据,后者主要负责渲染部分
  • 我们平时最常用到的应该是Input对应到交互事件的节点,以及onUpdate常用于用户逻辑每帧更新

补充 2:自问自答

  • Tinoe 定位是游戏引擎吗,为什么不用 ECS 架构

    • Tinoe 不是游戏引擎,我们想做的是偏向互动的基于 WebGL 的渲染引擎,WebGL 甚至于 OpenGL 的能力都是不足的,游戏引擎大佬们看来我们就是小打小闹,而事实确实如此。我们核心想做的是前端友好的互动引擎,专注 web3D,我们的能力不如游戏大佬,但我们也有热爱
    • ECS 有万好,但是对我们的用户不友好,我们想尽可能简化我们的 API,降低 web3D 的门槛
  • 为什么需要手动回收碰撞体

    • 有想过内部自动回收掉,但是想了想让用户去管理碰撞体也挺好的,敢作不敢当可不行
    • 结论:我们马上改,也许文章发出去的时候已经改了
  • 为什么不是主角和敌机都继承自飞机类,子弹有一个子弹类

    • 这里的代码实现肯定不是最佳实践,更优雅的写法,更少行数代码实现等待大家发掘
  • 为什么 demo 不做最佳实践

    • 因为懒
  • demo 都不用心写,是不是态度不好

    • 其实是比较忙,写 demo 的目的更多还是为了做引擎能力完备性的验证,所以实在抱歉
  • 不止 100 行代码呀

    • 是的,我是标题党
    • 但是你可以做到的 💪🏻
  • web3D 如何入门,祖传学习资料?

    • 如果你想了解 Tinoe,我们的 Tinoe 官网 最近会迎来升级,敬请期待
    • 如果你想了解更多 web3D,推荐看看 WebGL 官方文档
    • 如果你想做渲染相关的工作,强烈推荐 bilibili 搜索 games101,或者直接去 Games 官网
    • 如果你想做引擎相关的工作,强烈推荐 bilibili 搜索 games104,或者直接去 Games 官网
    • 如果你的业务有需要,欢迎使用 Tinoe,我们将竭诚为您服务,各种合作欢迎联系~