• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

探索Pixi.js的潜力打造专业级网页游戏《消消乐》(上)

武飞扬头像
知吾猪
帮助1

网页游戏以其便携性通常内嵌在各大app中,通过提供沉浸式的游戏体验拉近用户与app之间的距离,最终将用户流量转换为具体的物质价值,这就是网页游戏的价值之一。

本文将带你从零使用Pixi.js打造一款专业级别的网页游戏---消消乐,在线体验网址。通过本文,你会学习到如何使用Pixi及其生态开发一款专业的游戏,充分感受pixi在交互式应用上的魅力。

本文传达的技术或思想不会限制在pixi应用,比如静态资源处理,游戏路由管理器设计理念,缓存池,可暂停可恢复的异步队列管理器等等一系列最佳实战,你都可以直接或举一反三应用到自己的实际项目当中。

游戏主要界面预览

首页

学新通

准备状态

学新通

消除状态

学新通

游戏及结束状态

学新通

Pixi简介及其生态

Pixi.js是一款开源的、基于WebGL的2D渲染引擎,被广泛应用于创建交互式的、富有创意的Web应用程序比如游戏,DIY设计等。

在笔者使用pixi期间,个人非常喜欢它的以下特性:

  1. 灵活的API设计。使它可以方便地与其它库相结合,比如 pixi-react
  2. 遮罩系统。类似于CSS中的mask-image或PS中的剪切蒙版,我们可以很方便地将图像、容器等限制在其它图形图像的可视区域内。笔者之前写过一个基于Vue3 Pixi.js开发的 PC端手机壳DIY设计项目 ,就是得益于pixi灵活的API设计与遮罩系统,使它可以很方便地与Vue3自定义渲染器相结合并实现了可设计区域。目前处于staging阶段,不久的将来我也会对它进行分享。
  3. 较为丰富的生态。
    • @pixi/ui:基于pixi的,通用的,可扩展的UI组件库;
    • @pixi/layout:简单快捷地在游戏中创建响应式布局。
    • @pixi/sound:音频管理。基于WebAudio API。
    • AssetPack:静态资源打包器。通过它,我们可以生成manifest.json静态资源清单文件,打包纹理图集(类似于雪碧图),压缩图片,json等,格式转换如ttf转体积更小woff2格式,wav转兼容性更好的MP3格式等。这些功能可帮助我们优化静态资源,以加快加载时间、提高性能和改善用户体验。
    • pixi-spine:在pixi中应用spine动画

以上库我们在打造消消乐游戏中都会使用到,当然,还有大名鼎鼎的js动画库gsap以及家喻户晓的现代构建工具 vite

此外,pixi是基于WebGL的,这使得它能够利用硬件加速来实现高效的渲染,据官方透露,PixiJS V8将会对WebGPU进行更好的支持,届时其渲染性能也会更上一层。pixi社区也提供了丰富的滤镜库以帮助我们实现各种图像效果比如模糊,渐变,色彩调整,马赛克等等。当然,对ts的支持也至关重要,在使用这种多API的库时,我可不希望点一个对象没有任何提示~。

未来pixi还会进一步支持3D渲染,游戏引擎,脚手架等,使游戏开发更轻松,更高效。总之,pixi的潜力很大,在构建数字化内容网站时,限制我们的往往是想象力,而不会是pixi或其它技术本身了。更多详情详见pixi官网,下面我们进入正式的开发当中。

准备工作

在开发前我们需要准备一些开发工具,它们可以帮助我们更好更快地理解pixi。

1.PixiJS Devtools。一款用于开发调试pixi应用的浏览器扩展插件,在火狐、谷歌扩展商店都有提供下载。通过它我们可以看到画布中元素的嵌套的关系,重要的是我们可以看到元素支点(pivot或anchor)的位置,这对于元素的布局太有用了!

2.AI工具。先不说pixi目前没有中文文档,即使翻译成中文,其中有一些专有名词在我们没有足够的实战经验下也会难以理解(这一点真的劝退大部分人),比如元素属性tiny,texture,变换矩阵等,这就需要AI工具帮我们快速解读。目前免费的这里推荐codeGeeXcodeium,VSCode扩展中直接搜索下载即可,不过貌似授权需要魔法。如果你已经有心仪的了可以忽略这里。

3.翻译工具。笔者使用的是 沉浸式翻译

当然,还有一款心仪的代码编辑器,以及一颗对游戏或好奇或热爱的心。Let us go.

打包静态资源

使用pnpm init初始化项目,并且安装相关依赖:

安装生产依赖

pnpm i @pixi/sound @pixi/ui@0.5.0 gsap pixi-spine pixi.js

安装开发依赖

pnpm i vite @assetpack/cli @assetpack/plugin-compress @assetpack/plugin-ffmpeg @assetpack/plugin-json @assetpack/plugin-manifest @assetpack/plugin-texture-packer @assetpack/plugin-webfont -D
  1. @assetpack/cli:assetpack脚手架。
  2. @assetpack/plugin-compress:使用 sharp 压缩图像的AssetPack插件。
  3. @assetpack/plugin-ffmpeg:使用ffmpeg转换音视频文件的AssetPack插件。这里需要在系统上安装ffmpeg,首先在ffmpeg官网下载系统对应的安装包,解压,安装。最后需要配置系统环境变量,以下是mac操作:
# 打开.zshrc,没有就新建
open ~/.zshrc

# 加入以下代码,$PATH后面跟ffmpeg可执行程序所在的文件夹
export PATH=$PATH:/Users/用户名/Downloads

# 使环境变量生效
source ~/.zshrc

最后在终端输入ffmpeg -h检验一下,系统就会在指定的$PATH中查找对应的可执行程序进行启动。

  1. @assetpack/plugin-json:用于压缩JSON文件的AssetPack插件。
  2. @assetpack/plugin-webfont:用于将ttf、otf、woff和svg格式字体转换成体积更小的woff2字体的AssetPack插件。
  3. @assetpack/plugin-manifest:生成manifest.json资源清单文件,这是pixi V7版本中加载资源清单的最佳实践。
  4. @assetpack/plugin-texture-packer:使用Texture Packer:生成纹理图集(类似雪碧图或者序列帧集合)的AssetPack插件,可用于优化请求次数。

使用assetpack打包资源

新建.assetpack.js,配置如下:

import { compressJpg, compressPng } from '@assetpack/plugin-compress';
import { audio, ffmpeg } from '@assetpack/plugin-ffmpeg';
import { json } from '@assetpack/plugin-json';
import { pixiManifest } from '@assetpack/plugin-manifest';
import { pixiTexturePacker } from '@assetpack/plugin-texture-packer';
import { webfont } from '@assetpack/plugin-webfont';

export default {
    entry: './raw-assets', // 原始资源存放位置
    output: './public/assets/', // 打包后存放位置
    cache: false, // 不使用缓存
    plugins: {
        webfont: webfont(), // 字体转woff格式
        compressJpg: compressJpg({ // 压缩jpeg
            compression: {
                quality: 90
            }
        }),
        compressPng: compressPng(), // 压缩png
        // audio: audio(), // 快捷配置将wav转mp3,但会同时生成ogg格式
        // 因此这里使用ffmpeg进行更详细的配置
        ffmpeg: ffmpeg({
            inputs: ['.wav'],
            outputs: [
                {
                    formats: ['.mp3'],
                    recompress: true, // 是否重新压缩。比如mp3转mp3也压缩
                    // options必须提供,可以为空对象
                    options: {
                        // audioBitrate: 96,
                        // audioChannels: 1,
                        // audioFrequency: 48000,
                    }
                },
            ]
        }),
        json: json(), // 压缩json
        texture: pixiTexturePacker({
            texturePacker: {
                removeFileExtension: true, // 移除扩展名
            }
        }),
        // 带{m}标识的文件夹内的文件都会添加到manifest清单
        manifest: pixiManifest({
            output: './public/assets/assets-manifest.json'
        }),
    },
};

在.assetpack.js配置中,由于我们使用到了es6 module,因此需要在package.json中配置type: "module"。然后在package.json中添加npm script:

{
    "assetpack": "assetpack"
}

最后运行一下assetpack命令pnpm run assetpack即可打包资源。关于assetpack插件用法请自行查阅文档,最后简单看下打包结果。

学新通

  1. common是游戏欢迎页,游戏页,游戏结果页的共同资源。
  2. game是游戏页需要的资源。
  3. home是游戏欢迎页需要的资源。
  4. preload是加载页需要的资源。
  5. result是游戏结果页需要的资源。
  6. assets-manifest.json就是所资源的清单文件。 它的格式大概如下:
{
  "bundles": [
    {
      "name": "preload",
      "assets": [
        {
          "name": [
            "preload/cauldron-skeleton.atlas"
          ],
          "srcs": [
            "preload/cauldron-skeleton.atlas"
          ],
        }
        // ...
      ]
    },
    {
      "name": "home", 
      "assets": [
        // ...
      ]
    },
  ]
}

学新通

使用vite启动一个最简单的pixi项目

1.新建src/app.ts

创建并导出一个Pixi应用

import { Application, Text } from "pixi.js";
import { isDev } from "./utils/is";

Text.defaultResolution = 2;
Text.defaultAutoResolution = false;

export const app = new Application<HTMLCanvasElement>({
    backgroundColor: 0xffffff,
    backgroundAlpha: 0,
    resolution: 2,
})

isDev() && (globalThis.__PIXI_APP__ = app);
  • Text.defaultResolution = 2; 表示文字抗锯齿
  • isDev() && (globalThis.PIXI_APP = this); 开发环境启用Pixi DevTool

2.新建src/main.ts

引入app并将canvas元素(即app.view)添加到页面上。

import { app } from "./app";

async function init() {
    // add canvas element to body
    document.body.append(app.view)

    // hide loading
    document.body.classList.add('loaded')
}

init()

3.新建index.html

应用入口文件,引入main.ts,并添加值为"module"的type属性,同时添加CSS loading动画。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>game match3</title>
    <style>
      html,
      body {
        overflow: hidden !important;
        height: 100% !important;
        margin: 0;
        padding: 0;
        background-color: #fff;
        position: fixed;
      }
      body::after {
        content: "";
        position: fixed;
        z-index: 1000;
        top: 50%;
        left: 50%;
        width: 70px;
        height: 70px;
        margin: -35px 0 0 -35px;
        border: 5px solid rgba(250, 204, 21, 0.5);
        border-right-color: rgb(250, 204, 21);
        border-radius: 50%;
        transition: opacity 0.3s;
        animation: rotateCircle 0.7s linear infinite forwards;
        pointer-events: none;
      }

      @keyframes rotateCircle {
        to {
          transform: rotate(360deg);
        }
      }

      body.loaded::after {
        opacity: 0;
      }
    </style>
  </head>
  <body>
    <script src="./src/main.ts" type="module"></script>
  </body>
</html>

4.新建vite配置文件

import { defineConfig } from "vite"

export default defineConfig({
    server: {
        port: 5000,
        open: true,
    },
})

5.创建启动脚本

"scripts": {
    "assetpack": "assetpack",
    "start": "vite",
    "preview": "vite preview",
    "build": "vite build"
}

现在当我们在终端执行pnpm run start即可启动项目。vite会优先加载index.html并开始加载main.ts,经历一系列vite服务器中间件,插件容器等处理后,摇身成一系列浏览可执行的js文件,最终在页面上显示我们canvas元素。打开控制台,我们的调试工具也准备就绪了,_Container就是画布的根容器,我们可以通过app.stage访问到它。

学新通

不过页面上现在是空白的,因为我们没有在画布上添加任何内容。在绘制元素之前,我们需要先加载我们需要的静态资源。

加载静态资源

在pixi应用中,初始化manifest资源清单,配合backgroundLoadBundle后台加载,是加载及应用资源不错的最佳实践。但是后台加载并不意味着我们不需要在程序手动加载资源,如果当我们加载的资源在后台已经预加载完毕,它会被立即返回。

// utls/assets.ts


/** List of assets grouped in bundles, for dynamic loading */
let assetsManifest: ResolverManifest = { bundles: [] };

/** Load the assets json manifest generated by assetpack */
async function fetchAssetsManifest(url: string) {
    const response = await fetch(url);
    const manifest = await response.json();
    if (!manifest.bundles) {
        throw new Error('[Assets] Invalid assets manifest');
    }
    return manifest;
}

/** Initialise and start background loading of all assets */
export async function initAssets() {
    // Load assets manifest
    assetsManifest = await fetchAssetsManifest('assets/assets-manifest.json');

    // Init PixiJS assets with this asset manifest
    await Assets.init({
        manifest: assetsManifest,
        basePath: 'assets',
        texturePreference: {
            resolution: 1
        }
    });

    // Load assets for the load screen
    await loadBundles(['preload']);

    // List all existing bundles names
    const allBundles = assetsManifest.bundles.map((item) => item.name);

    // Start up background loading of all bundles
    Assets.backgroundLoadBundle(allBundles);
}

首先,我们通过fetch加载资源清单文件,并调用Assets.init初始化初始化资源。由于我们资源放在public/assets当中,因此这里指定basePath: 'assets'

学新通

学新通

texturePreference表示你期望使用的图片分辨率。从下图中我们可以看到,assetpack默认帮我们生成了两种分辨率图片。

学新通

resolution: 1表示使用@1x图片,也就是raw-assets中的原始大小;resolution: 0.5表示使用@0.5x图片,这是缩小了1倍的图片。在这里你可以通过window.devicePixelRatio判断到底需要加载哪种图片。为了确保pixi能正确加载指定分辨率的图片,我们需要为pixi加入如下扩展:

// utils/assets.ts
import { settings, extensions, resolveTextureUrl, ResolveURLParser, ExtensionType } from 'pixi.js';

export const resolveJsonUrl = {
    extension: ExtensionType.ResolveParser,
    test: (value: string): boolean =>
        // @ts-expect-error
        settings.RETINA_PREFIX.test(value) && value.endsWith('.json'),
    parse: resolveTextureUrl.parse,
} as ResolveURLParser;

extensions.add(resolveJsonUrl);

当然,我们也可以准备多份清单文件如 manifest@x3/@x2/@x1.json,通过判断window.devicePixelRatio加载对应的清单文件。

紧接着,我们通过loadBundles立即加载名为preload的bundle资源文件列表。 加载前,我们先检查需要加载的bundles是不是资源清单里的,然后过滤出还没有加载的bundles列表进行加载,最后将加载完后的bundles添加到loadedBundles数组。

/** Store bundles already loaded */
const loadedBundles: string[] = [];

/** Check if a bundle exists in assetManifest  */
function checkBundleExists(bundle: string) {
    return !!assetsManifest.bundles.find((b) => b.name === bundle);
}

/** Load assets bundles that have not been loaded yet */
export async function loadBundles(bundles: string | string[]) {
    if (typeof bundles === 'string') bundles = [bundles];

    // Check bundles requested if they exists in assetsManifest
    for (const bundle of bundles) {
        if (!checkBundleExists(bundle)) {
            throw new Error(`[Assets] Invalid bundle: ${bundle}`);
        }
    }

    // Filter out bundles already loaded
    const loadList = bundles.filter((bundle) => !loadedBundles.includes(bundle));

    // Skip if there is no bundle left to be loaded
    if (!loadList.length) return;

    // Load bundles
    await Assets.loadBundle(loadList);

    // Append loaded bundles to the loaded list
    loadedBundles.push(...loadList);
}

最后,通过Assets.backgroundLoadBundle(allBundles)将所有的bundles放到后台加载,这里你不用担心会再次加载preload的bundle资源。

注意:Assets.backgroundLoadBundle(allBundles)之前没有await,这意味着在加载完preload的bundle之后,你就可以开始着手preloadScreen的绘制了,而不必等待所有bundles加载完毕。在用户停留preloadScreen的时间里,下一个页面的资源也许已经加载完毕了,当用户跳转到下一个页面,页面的内容就会立即呈现出来。

但我们依然需要防止用户跳转到下一个页面,后台并没有加载资源完毕的情况。因此,我们需要在进入下一页面前,判断下一页面的所需资源加载是否完毕。我们会在下文的路由管理器中完善这一点,大家有个印象即可。

至此,关于资源加载已经大致介绍完毕。

实现游戏的路由管理

一个应用通常包含多个路由,在游戏中每个路由就是一个screen(本质就是一个pixi的Container容器)比如loadScreen加载页gameScreen游戏页,resultScreen游戏结果页等。这些screens会在应用中不断切换,而在切换的过程往往伴随一系列生命周期比如当前页的隐藏、卸载、重置,新页面的资源加载、初始化、resize、显示等等。我们通过实现一个路由管理类来统一管理这些切换的动作以及生命周期等等。

1.核心实现

import { Container } from 'pixi.js';
import { areBundlesLoaded, loadBundles } from './assets';
import { app } from '../app';

/** Interface for app screens */
interface AppScreen extends Container {
    /** Prepare screen, before showing */
    prepare?(): void;
    /** Resize the screen */
    resize?(width: number, height: number): void;
    /** Update the screen, passing delta time/step */
    update?(delta: number): void;
    /** Show the screen */
    show?(): Promise<void>;
    /** Hide the screen */
    hide?(): Promise<void>;
    /** Reset screen, after hidden */
    reset?(): void;
}

/** Interface for app screens constructors */
interface AppScreenConstructor {
    new(): AppScreen;
    /** List of assets bundles required by the screen */
    assetBundles?: string[];
}

class Navigation {
    /** Container for wrap screens */
    public container = new Container();

    /** Application width */
    public width = 0;

    /** Application height */
    public height = 0;

    /** Constant background view for all screens */
    public background?: AppScreen;

    /** Current screen being displayed */
    public currentScreen?: AppScreen;

    constructor() {
        this.container.name = "navigation"
    }

    /** Set the  default load screen */
    public setBackground(ctor: AppScreenConstructor) {
        this.background = new ctor();
        this.background.name = "background"
        this.addAndShowScreen(this.background);
    }

    /** Add screen to the stage, link update & resize functions */
    private async addAndShowScreen(screen: AppScreen) {
        // Add navigation container to stage if it does not have a parent yet
        if (!this.container.parent) {
            app.stage.addChild(this.container);
        }

        // Add screen to stage
        this.container.addChild(screen);

        // Setup things and pre-organise screen before showing
        if (screen.prepare) {
            screen.prepare();
        }

        // Add screen's resize handler, if available
        if (screen.resize) {
            // Trigger a first resize
            screen.resize(this.width, this.height);
        }

        // Add update function if available
        if (screen.update) {
            app.ticker.add(screen.update, screen);
        }

        // Show the new screen
        if (screen.show) {
            screen.interactiveChildren = false;
            await screen.show();
            screen.interactiveChildren = true;
        }
    }

    /** Remove screen from the stage, unlink update & resize functions */
    private async hideAndRemoveScreen(screen: AppScreen) {
        // Prevent interaction in the screen
        screen.interactiveChildren = false;

        // Hide screen if method is available
        if (screen.hide) {
            await screen.hide();
        }

        // Unlink update function if method is available
        if (screen.update) {
            app.ticker.remove(screen.update, screen);
        }

        // Remove screen from its parent (usually app.stage, if not changed)
        if (screen.parent) {
            screen.parent.removeChild(screen);
        }

        // Clean up the screen so that instance can be reused again later
        if (screen.reset) {
            screen.reset();
        }
    }

    /**
     * Hide current screen (if there is one) and present a new screen.
     * Any class that matches AppScreen interface can be used here.
     */
    public async showScreen(ctor: AppScreenConstructor) {
        // Block interactivity in current screen
        if (this.currentScreen) {
            this.currentScreen.interactiveChildren = false;
        }

        // Load assets for the new screen, if available
        if (ctor.assetBundles && !areBundlesLoaded(ctor.assetBundles)) {
            // Load all assets required by this new screen
            await loadBundles(ctor.assetBundles);
        }

        // If there is a screen already created, hide and destroy it
        if (this.currentScreen) {
            await this.hideAndRemoveScreen(this.currentScreen);
        }

        // Create the new screen and add that to the stage
        this.currentScreen = new ctor();
        await this.addAndShowScreen(this.currentScreen);
    }

    /**
     * Resize screens
     * @param width Viewport width
     * @param height Viewport height
     */
    public resize(width: number, height: number) {
        this.width = width;
        this.height = height;
        this.currentScreen?.resize?.(width, height);
        this.background?.resize?.(width, height);
    }
}

/** Shared navigation instance */
export const navigation = new Navigation();

现在,我们可以通过navigation.showScreen(LoadScreen)切换到加载页,一旦showScreen调用,内部会帮我们触发一系列生命周期事件:

  1. disable老页面。
  2. 加载新页面需要的bundle(screen类上定义的一个静态属性assetBundles)。
  3. 隐藏并卸载老页面,伴随着定时器移除,状态重置等动作。
  4. 展示新页面。实例化Screen,prepare初始化,resize设置大小位置(触发resize事件同样会触发该生命周期,这是实现响应式的核心),添加定时任务,开始显示。

可以看到,整个切换动作还是比较清晰的。这里,我们还额外添加了setBackground方法用于设置整个路由容器的背景,也就是说所有screen共用的背景。

2.优化:实例池

游戏中,会伴随着实例化大量的、不同的类,因此我们实现一个实例池,以复用回收的实例,从而避免过多的实例化。

核心原理在于定义一个构造函数到构造函数实例池的Map,对外暴露一个get方法,首次取用时实例化类,之后优先从闲置的实例中获取。同时暴露一个giveBack方法用于回收实例。

/**
 * Pool instances of a certain class for reusing.
 */
class Pool<T extends new () => InstanceType<T> = new () => any> {
    /** The constructor for new instances */
    public readonly ctor: T;
    /** List of idle instances ready to be reused */
    public readonly list: InstanceType<T>[] = [];

    constructor(ctor: T) {
        this.ctor = ctor;
    }

    /** Get an idle instance from the pool, or create a new one if there is none available */
    public get() {
        return this.list.pop() ?? new this.ctor();
    }

    /** Return an instance to the pool, making it available to be reused */
    public giveBack(item: InstanceType<T>) {
        if (this.list.includes(item)) return;
        this.list.push(item);
    }
}

/**
 * Pool instances of any class, organising internal pools by constructor.
 */
class MultiPool {
    /** Map of pools per class */
    public readonly map: Map<new () => any, Pool> = new Map();

    /** Get an idle instance of given class, or create a new one if there is none available */
    public get<T extends new () => InstanceType<T>>(ctor: T): InstanceType<T> {
        let pool = this.map.get(ctor);
        if (!pool) {
            pool = new Pool(ctor);
            this.map.set(ctor, pool);
        }
        return pool.get();
    }

    /** Return an instance to its pool, making it available to be reused */
    public giveBak(item: InstanceType<any>) {
        const pool = this.map.get(item.constructor);
        if (pool) pool.giveBack(item);
    }
}

/**
 * Shared multi-class pool instance
 */
export const pool = new MultiPool();

使用上很简单,通过get代替new,如果需要复用实例,通过giveBack将其回收。

import { pool } from './pool';

const a = pool.get(A);
// ...
pool.giveBack(a);

现在我们就可以将navigation中的 this.currentScreen = new ctor() 代替为 this.currentScreen = pool.get(ctor)

最后需要说明的是,弹窗也是一类特殊的screen,也会被navigation所管理,我们会在后面对其进行支持。下面我们开始游戏中Screen的开发。

实现LoadScreen加载页

本文不会花费过多篇幅讲解pixi及其相关库的用法,如有需要请自行查阅相关文档或使用AI工具。

  1. 加载页的功能很简单,居中显示一段文字,在跳转页面前显示提示文字,等待0.3s后开始过渡到新页面。这里为了简单起见,没有使用preload相关的素材进行装饰。
  2. LoadScreen上定义了assetBundles静态属性,因此路由管理器会在进入当前页帮我加载完毕必要的preload bundle,也就是资源清单里定义的一系列assets。
  3. anchor与pivot。两者作用一致,类似CSS中的transform-origin。不同在于pivot是可展示对象共有的,以具体像素为单位;anchor是sprite和text独有的,更像是语法糖,0表示支点位于元素左上角,0.5:位于元素中间,1:位于右下角。值得注意的是,设置pivot会导致元素移动,因为位置受pivot影响,这是与transform-origin最大的不同。
  4. 类上还定义了resize,show,hide,作为新页面resize,show会被立即依此执行,当变为老页面,hide会被执行。期间当window resize,resize会被执行以便重新计算正确的位置即大小。
import { Container, Text } from 'pixi.js';
import gsap from 'gsap';
import { sleep } from '../utils/sleep';

/** Screen shown while loading assets */
export class LoadScreen extends Container {
    /** Assets bundles required by this screen */
    public static assetBundles = ['preload'];
    /** LThe loading message display */
    private message: Text;

    constructor() {
        super();

        this.message = new Text("正在加载...", {
            fill: 0x333333,
            align: 'center',
        });
        this.message.anchor.set(0.5);
        this.addChild(this.message);
    }

    /** Resize the screen, fired whenever window size changes  */
    public resize(width: number, height: number) {
        this.message.x = width * 0.5;
        this.message.y = height * 0.5;
    }

    /** Show screen with animations */
    public async show() {
        gsap.killTweensOf(this.message);
        this.message.alpha = 1;
    }

    /** Hide screen with animations */
    public async hide() {
        // Change then hide the loading message
        this.message.text = "加载完毕,游戏即将开始~";
        await sleep(300)
        gsap.killTweensOf(this.message);
        gsap.to(this.message, {
            alpha: 0,
            duration: 0.3,
            ease: 'linear',
        });
    }
}

使用TilingSprite实现背景

当你需要根据一个小贴图制作无限重复的背景,就可以考虑使用pixi内置的TilingSprite类。

  1. 这里我们通过Texture.from('background')就可以取到sprite图集里的背景图片纹理,非常简单,你不用考虑背景在图集里的位置。
  2. 最值得注意的是这里重写了Container的updateTransform方法,注入了使背景图朝direction方向无限移动的动画逻辑。原理在于,在PIXI内部会自动调用 updateTransform 方法来更新对象的变换矩阵,以确保对象在每一帧(requestAnimationFrame)都具有正确的位置和大小。实战中,当你修改了元素的变换矩阵,如果需要立即同步更新矩阵以获取最新变换矩阵信息,可以立即调用updateTransform就可以达到目的。
import { Container, Texture, TilingSprite } from 'pixi.js';
import { app } from '../app';

/**
 * The app's animated background based on TilingSprite, always present in the screen
 */
export class TiledBackground extends Container {
    /** The direction that the background should animate */
    public direction = -Math.PI * 0.15;
    /** The tiling sprite that will repeat the pattern */
    private sprite: TilingSprite;

    constructor() {
        super();

        this.sprite = new TilingSprite(
            Texture.from('background'),
            app.screen.width,
            app.screen.height,
        );
        this.sprite.tileTransform.rotation = this.direction;
        this.addChild(this.sprite);
    }

    /** Get the sprite width */
    public get width() {
        return this.sprite.width;
    }

    /** Set the sprite width */
    public set width(value: number) {
        this.sprite.width = value;
    }

    /** Get the sprite height */
    public get height() {
        return this.sprite.height;
    }

    /** Set the sprite height */
    public set height(value: number) {
        this.sprite.height = value;
    }

    /** Auto-update by overriding Container's updateTransform */
    public updateTransform() {
        super.updateTransform();

        this.sprite.tilePosition.x -= 0.5;
        this.sprite.tilePosition.y -= 0.5;
    }

    /** Resize the background, fired whenever window size changes  */
    public resize(width: number, height: number) {
        this.width = width;
        this.height = height;
    }
}

现在,我们来到main.ts,将加载页,背景添加到画布中,就可以看到具体的效果了。 当执行navigation.showScreen(Gamecreen);触发加载页的hide,显示"加载完毕,游戏即将开始~",0.3s就会开始过渡到游戏页。我们将在下节开始介绍游戏页。

// main.ts
function resize() {
    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;
    const minWidth = 375;
    const minHeight = 700;

    // Calculate renderer and canvas sizes based on current dimensions
    const scaleX = windowWidth < minWidth ? minWidth / windowWidth : 1;
    const scaleY = windowHeight < minHeight ? minHeight / windowHeight : 1;
    const scale = scaleX > scaleY ? scaleX : scaleY;
    const width = windowWidth * scale;
    const height = windowHeight * scale;

    // Update canvas style dimensions and scroll window up to avoid issues on mobile resize
    app.renderer.view.style.width = `${windowWidth}px`;
    app.renderer.view.style.height = `${windowHeight}px`;
    // Scroll the window to the top to avoid issues on mobile resize
    window.scrollTo(0, 0);

    // Update renderer and navigation screens dimensions
    app.renderer.resize(width, height);
    navigation.resize(width, height);
}

async function init() {
    // add canvas element to body
    document.body.append(app.view);

    // Trigger the first resize and do it on window resize
    resize();
    window.addEventListener("resize", resize);

    // Load assets
    await initAssets();

    // hide loading
    document.body.classList.add("loaded");

    // Add a persisting background shared by all screens
    navigation.setBackground(TiledBackground);

    // Show initial loading screen
    await navigation.showScreen(LoadScreen);

    // go to game screen
    // navigation.showScreen(Gamecreen);
}

init();

总结

本章节,我们介绍了Pixijs及其丰富的生态,以及未来的展望。pixi具有非常大的潜力,可以帮助我们构建优秀的2D数字内容应用。鉴于此,我们开始着手基于pixi.js打造一款专业的游戏--消消乐。期间借助assetpack打包静态资源,vite搭建现代应用开发环境,实现了游戏的资源加载,路由管理及加载页与背景的开发,这些开发要素在网页游戏中非常常见。

展望

预计本系列共3篇,中篇:游戏通用玩法。下篇:游戏高阶玩法--特殊元素。如果你喜欢或者对你有所帮助,可以帮忙点个小红心。笔者能力有限,文中如有错误,欢迎不吝指出。

最后,欢迎在评论区留下你的模式最高分。

附:消消乐github地址

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgahjee
系列文章
更多 icon
同类精品
更多 icon
继续加载