• 【Android LibGDX游戏引擎开发教程】第04期:各个模块的详细介绍


              博主思来想去,觉得还是想把这个教程写的再细一点,让读者能够更清楚的了解LibGDX这个游戏引擎整体的架

    构,所以也就总结出了这样一篇文章。

        

    一、模块概述

    作为游戏开发人员,我们需要一系列的系统组件是我们能够制作是我们想要的游戏:

    <1> 应用程序框架,处理我们应用程序的主循环和生命周期(创建,暂停,恢复,销毁);

    <2> 图形模块,它提供了一种方法让我们在屏幕上画一些东西;

    <3> 音频模块,播放音乐和声音效果;

    <4> 输入模块接收来自鼠标,键盘,触摸屏,重力感应设备等用户输入;

    <5> 文件I / O模块读写数据例如:纹理,地图或配置文件中的数据。

           libgdx包含上述名单涉及到的所有模块,对于每一个模块存在一个或多个为每个平台实现的Java接口,这些实现

    为后端。作为一个程序员,你不需要关心你的后端与实际工作,你只工作在每个后端实现公共接口,唯一的平台

    定的代码,你只需要写libgdx的一个具体的应用实施实例,如一个用于PC和一个用于Android。

    二、各个模块概述

    下图表显示了在一个简单的游戏构架中的各个模块:

    LibGDX 包括几个模块,对每一个典型的游戏架构步骤提供服务:

        <1> Input - 对所有平台提供一个统一的输入模块并处理。支持键盘,触摸屏,传感器,鼠标等;

        <2> Graphics - 开启屏幕上图像的绘制,支持OpenGL ES;

        <3> Files - 抽象所有平台文件访问为读写操作提供合适的方法;

        <4> Audio - 在所有平台上有利于声音的记录和播放;

        <5> Math - 很实用的模块,对于游戏开发提供最快的数学计算;

        <6> Physics - 对于Box2D的完整封装。

    这些模块在API的各个包中,如下图所示:

    三、各个模块详解

    1、Libgdx游戏引擎中的最主要几个模块,也是游戏开发中经常用到最多的模块,如下图所示:

           在图中的那些名字实际上就是那些你在写基于Libgdx应用所要用到的公共接口的名字,Application负责管理每一

    个子模块,有可能只有一个应用,也有可能所有子模块的都有一个实例。让我们仔细看看这些不同的模块.

    2、应用模块(Application)

          应用模块负责让你的游戏在特定平台上运行,它将实例化子模块,在桌面PC上创建一个窗口或者在Android上面

    一个Activity,并且显示处理UI线程。你可以通过以下三种方法同应用交互:

    <1> 访问子模块;

    <2> 查询信息,比如当前应用运行的平台信息;

    <3> 注册一个ApplicationListener。

         最后一种方法允许你得到生命周期时间的通知以及对这些事件进行反馈,Libgdx应用的生命周期简化了Android

    Activity生命周期的模型:

     

          是的,3种方法对于任何人来说都足够了。每次这些事件一发生,Application将会调用该注册ApplicationListener的指定方法。因此,在何时这些事件发生了呢?

    <1> ApplicationListener.resume():在应用程序启动或者应用从一个暂停状态恢复的时候被调用一次;

    <2> ApplicationListener.pause():在应用程序被中断或者被一个外部事件暂停,这些外部事件可能是:一个来电,用

    户按下Home键,应用程序根据用户的操作将会被恢复或者销毁;

    <3> ApplicationListener.destroy():在应用程序将要销毁的时候被调用。

          显然,在桌面PC上面没有暂停事件,但是如果我们想要在Android设备上面运行应用程序,我们仍然要准备好暂

    停事件。注册一个ApplicationListener不是强制的,然而这样做可以帮助你更好的处理一些事情,比如:在程序开始

    的时候读取配置文件,在程序销毁的时候销毁程序所使用的一切资源。暂停事件在图像模块中有着重要的作用,接下

    来我们将会涉及到它,需要记住的是:暂停可能会发生。

         然而,你可能会问到主循环在哪儿?它不在这里,应用程序包装了依赖平台的UI线程,这些线程有基于事件的性

    质。我们不能在UI线程里面添加一个无限循环,因为这样会冻结整个应用程序,UI线程通常是基于事件的,这意味着

    当除非有事件需要处理,比如:键被按下或者鼠标点击,操作系统将使UI线程转入休眠,这不是放我们游戏主循环的

    地方。信不信由你,游戏具有天生的多线程性质,除了UI线程,libgdx为你生成了第二个线程。但它不属于应用模

    块,而是属于图像模块,所以接下来,我们来看看它都做了些什么?

     3、图形模块(Graphics Module)

         虽然有的游戏不需要不断的重新绘制屏幕,但是Libgdx假设你想要的游戏需要不间断的绘制屏幕,这是个简单的概

    念并且你不需要去关注任何脏标记或者去关注在某些游戏状态改变的情况下重新绘制屏幕。

         我们已经知道UI线程不是一直不间断的运行的,而是在事件的驱动下有操作系统调度运行的(粗略的认为)。这

    就是我们为什么实例化第二个线程,也就是我们通常说的渲染线程,这个线程是由Graphics模块所创建,Graphics模

    块本身由应用模块在程序启动的时候实例化。

           渲染线程执行一个无限循环,由于应用程序的生命周期事件,它可能会被暂停或者恢复。作为开发者,你可以通

    过注册一个RenderListerner将这个线程与图形模块连接起来(是的,我太喜欢回调了...)。与ApplicationListener相

    的是,RenderListerner对由图像模块渲染线程触发的特定事件做出回应:

          在我们进入这些方法的详解之前,我先给你介绍一些细节:libgdx使用OpenGl ES,这个是移动设备上硬件加速图

    像渲染的工业标准。OpenGL ES同一个叫做EGL的东西一起使用,EGL负责设立一个窗口系统和图像硬件的链接,

    它创建一个渲染表面,用户可以添加一个用户界面组件到上面,图形硬件可以不通过使用UI工具包直接渲染。

    OpenGL ES自身额外的添加了一个叫做图形上下文的东西,他用于管理驻留在显存中的图形资源,举个例子:这些

    资源可以是一个纹理,它从本质上来说就是一张位图,OpenGLES将其建立在EGL的表面和背景,通过图形处理处理

    器获得高质量的图片。

    通过以上信息,对于RenderListerner方法,我们有了一些感性的认识,接下来,我们将详细的介绍它:

    <1> RenderListener.surfaceCreated():在EGL的绘图界面和上下文被建立,并且我们已经准备通过OpenGL画图的

    被调用,这一般发生在应用第一次启动或者当应用从暂停状态到恢复状态;

    <2> RenderListener.surfaceChanged():在EGL的绘图界面大小发生改变的时候被调用,这一般发生在屏幕方向发生

    变或者桌面上的窗口大小改变;

    <3> RenderListener.render():不断的被调用,而且这里是随着游戏的运行实现我们游戏逻辑的最好的地方,每次从

    个方法返回时Libgdx会确保你在方法内所绘制的东西得以在屏幕上显示,如果应用程序被暂停,那么渲染线程也将

    停,因此这个方法将不会被调用;

    <4> RenderListener.dispose():在应用程序即将死亡的时候被调用,这里是做一些清理工作,比如记录高分或者保存

    些信息的可选择的地方。

          现在,所有的都看起来很棒和清晰,我们知道在哪里放置游戏主逻辑循环(RenderListener.render()),并且我们

    也知道其它的那些方法的意思,但是仍然还有一些疑惑没有得到解答。

         

        请记住:EGL上下文负责管理显存里面所有的图片资源,主要是纹理和几何单元。现在,每一次一个OpenGL ES

    Android应用程序暂停,上下文和绘图界面的东西都会被销毁,这也意味着在显存中的所有图片资源也要被销毁。当

    程序再被回复的时候,一对新的EGL上下文和绘图表面显示的被创建,然而我们的资源已经消失,在下一次

    RenderListerner.render()方法被调用后,我们很可能会看到一个空白或者白色的画面。

       

          通常情况下,你因此需要了解哪些资源当前在显存中,当应用程序暂停的时候,你必须标志那些被销毁的资源,

    当程序恢复,RenderListerner.surfaceCreated()方法再次被调用的时候,你必须重新加载那些被销毁的资源,以便下

    次RenderListerner.render()方法调用的时候使用,这是令人相当讨厌的。

         这就是为什么libgdx将自动重新加载所有图片资源的原因,这些资源成为被管理者,这意味着libgdx将会管理他

    们,使他们永远不会被销毁,除非你希望销毁它们。管理资源不耗费额外的内存,所以使用它们不费吹灰之力。

         然而,有一种情况,你想要使用非托管的图形资源,如果你想在程序运行的时候操作纹理的内容,你将不能使用

    个托管的纹理。我将在以后详细的介绍这点,现在你只需要记住在libgdx中所有的图片资源都是被托管的,你永

    不需要担心一个EGL的上下文或者绘图表面丢失。

         那些拥有OpenGL ES经验的人或许已经在怀疑我是不是吃错药了,显然,在桌面环境下没有通过Java使用的

    OpenGL ES实现。但是,现在有了,Libgdx通过Windows和Linux上的标准OpenGL API模拟了OpenGL ES,几乎支

    持所有的特性,比如定点数学等等。现在唯一不支持的只有固定的顶点缓冲对象和一些小的getter方法,我们将在后

    面详细介绍这一问题。

       

         Graphics接口提供了一些创建一系列平台依赖图像资源的方法,其他图形资源是平台无关的,可以通过Libgdx中相

    关的类自由的创建,我们先看一下这些通过图形接口创建的资源:

        <1> Pixmap(像素图):是一个围绕平台相关位图的包装器,像素图可以从文件创建,或者指定宽、高和像素格

    式,他们存储在普通的RAM中,在EGL上下文丢失的情况下不会被破坏,像素图可以直接被Libgdx渲染.在几乎所有情

    下,他们由纹理构造器所创建;

       

       <2> Texture(纹理):实际上就是显存中的像素图.如同前文所提到的那样,当EGL上下文丢失的时候它将被销

    毁,纹理可以从像素图中创建,或者指定特定的宽、高和像素格式,或者是指定文件,接下来的案例中,纹理将由

    libgdx管理,否则,纹理没有管理者,你不得不小心翼翼的重新构造他们当上下文丢失后。非托管型纹理在创建后可

    以被操于此相反由于托管型是静态的所以不能被操作,纹理可以直接用于渲染;

      

       <3> Font(字体):是一个围绕平台相关字体的包装器,字体可以从文件中加载或者通过指定的文字名称。此外,

    有宽度,以及格式,比如粗体、斜体等。字体还拥有一个内部纹理,因此它将被EGL上下文丢失所影响,作为一个

    的库,libgdx将会自动为你处理此类的丢失。因此字体也是托管资源,可以直接使用于渲染。 

       以下是可以不使用Graphics接口初始化的资源:

        <1> Mesh(网格):作用是一个以顶点格式保存点、线、三角形集合,网格通常驻留在显存中。因此也受到EGL上下

    文丢失影响,网格也是托管型图形资源,因此你不必担心。在libgdx中所绘制的每个物体都会被转化为一个网格,平

    转移到图形处理器渲染,大多数时候,你设置了一个纹理,它将被应用到这个网格的几何形状中,对于你们中的

    OpenGL专家来说:一个网格上基本上包装了一个(索引)顶点数组,或者一个顶点缓冲对象,这取决于硬件;

        <2> SpriteBatch(精灵组):是渲染二维物体,比如精灵和文字的首选地方,就拿精灵来说,一个精灵简单指定一

    纹理,它拥有精灵的形象以及精灵位置和方向属性。libgdx中有一个更好的处理精灵的类Sprite可供选择,在文本的

    例中,用户可以指定文本的字体用于文本渲染,该SpriteBatch类相比其他所有开源精灵渲染方案,拥有极高的速度

    良好的渲染效果,精灵组在内部使用一个Mesh,因此也是受到上下文影响的。与往常一样,你同样不需要担心;

        <3> BitmapFont(位图字体):是渲染文本的另一种方法,不同于加载一种系统字体或者在从文件中提供一个TTF

    体,位图字体使用AngleCode位图字体。你可以通过使用TWL主题编辑器或者一个叫做Hiero的工具创建这样的字

    体,这种格式将是你渲染文本的首选方式。不久后,我们将会对这种文本渲染有一个详细的介绍,位图字体的内部使

    用纹理,因此也是托管的;

       <4> ShaderProgram(着色程序):封装了OpenGL ES 2.0的顶点和片段着色器,这是一个方便的类,他简化了在

    OpenGL ES中管理着色的难度,由于着色器驻留在显存中,它受到上下文丢失的影响,它也是托管的;

       <5> FrameBuffer(帧缓冲器):封装了OpenGL ES2.0的帧缓冲对象,它同样是托管资源,尽管在上下文丢失的情

    下它们的内容将会被销毁。鉴于该帧缓冲区通常重绘每一帧,因此这应该不是一个大问题。

         纹理、字体、网格和精灵组都和OpenGL ES 1.0、1.1和2.0兼容的,着色程序和帧缓冲器只有在使用OpenGLES

    2.0的时候才可以用。因为OpenGL ES 2.0 API和OpenGL ES 1.x是不兼容的,像素图同OpenGL ES没有联系,因此

    可以在任何版本中使用。

         在整个libgdx中反复出现的主题围绕着释放和处理不使用的资源,在移动设备中内存很小,因此我们努力的去减少

    内存或者显存的使用,释放那些不在使用的资源。所有的图形资源都拥有dispose()方法,一旦你不再使用这个资源,

    你应该调用它,对于资源内存的显示管理的另一个原因是,其中的一些对于虚拟机来说是不可见的,因此这些不可能

    被当做垃圾收集。拿像素图来说,它的像素存储在本地堆中,而本地堆不是由虚拟机所管理的,所有的垃圾收集器看

    到的只是一个很小的占据了几个自己的类的实例,垃圾收集器可能会因此推迟收集此类的实例,导致我们在本地堆中

    的内存溢出。对于在显存中的OpenGL ES资源来说,道理是一样的,因此,一定要记住:始终要释放不再需要的资

    源。 

          如果你想要更底层的操作,你同样可以访问已绑定的OpenGL ES.Graphics接口拥有多个getter方法,它将返回一

    个如下接口的实现:

    <1> GL10:为您提供所有的OpenGLES1.0的功能;

    <2> GL11:为您提供所有的OpenGLES1.1的功能,它实际上是从GL10上扩展而来,所以你也可以通过调用该接口,

    使用OpenGL ES1.0的所有功能;

    <3> GL20:为您提供所有的OpenGLES2.0的功能,如果你想要取得所有着色器,这就是你要使用的;

    <4> GLCommon:包含了OpenGL ES1.x和OpenGL ES 2.0所共有的所有功能,例如glTexImage2D等。

         在次强调,OpenGL ES 1.x和2.0是不兼容的,在启动的时候就必须决定使用哪个版本,这个通常在构建一个由后

    所提供Application实现类的时候完成。Graphics接口提供给你一些方法去检查当前正在运行的平台下可以获得哪个

    OpenGLES实现,如果你在一个没有OpenGL ES 2.0支持下的环境中指定使用GL20上下文,libgdx将会退回到

    OpenGL ES 1.x,因此对于一些老的设备,你可以拥有单独的方法渲染。

         Graphics包为了你的图形编程需求提供了很多有用的类,比如纹理地图、精灵、摄像机、3D格式文件装载器等

    等,我们将会在以后的章节中讨论这些,如果你对所有这些都很感兴趣,那请下载SVN源码。

     4、声音模块(Audio Module

        我们的视觉效果应该由一些效果很好的重金属音乐(或者科技舞曲如果你是这种类型的)支持,爆炸是任何一款卖

    的很好的游戏的一部分。因此Libgdx拥有一个专门的音频模块,他提供两种类型的资源:声音和音乐。

    <1> Sound(声音)类旨在播放声音效果,通常你从一个文件中加载声音效果,根据某些事件,例如僵尸脑袋爆炸,

    调用Sound.play()方法播放它。声音通常很短,在几秒钟的范围内,他们会在内存中加载和解码,如果它们长时间的

    运行,很可能会占据很大的一些内存;

    <2> Music(音乐)是你所想要使用的为了播放很长事件的声音(或者,好吧,就是背景音乐)的类,音乐是流的形

    式,那意味它仅仅是部分被装载进入了内存,根据文件格式解码,然后输出到音频设备中。

        声音和音乐都是系统资源,如果图形资源一样,你必须在它们不再被使用的时候,通过调用Sound.dispose()和

    Music.dispose()方法释放它们。

       Libgdx支持mp3、Ogg以及Wave文件格式,我建议你在大多数情况下使用Ogg格式。 

        如果你需要对音频设备更多的控制,那么你可以使用AudioDevice和AudioRecorder类,这两个类可以从Audio接口

    到。这些类允许你从物理音频设备输出PCM音频样本,也允许你使用麦克风记录PCM样本,不要忘记清理它们。 

        如果你想要做一个音频分析游戏,假如你想做下一代音乐战机,在这个特殊情况下,这里有一些类很可能会派上用

    场。在做任何事情之前,你需要取到音频文件的PCM样本,因此libgdx封装了libmpg123和Tremor的本地代码。

    Libmpg123是一个浮点数解码器,能从mp3文件中返回PCM数据,Java类是Mpg123Decoder,Tremor是一个有Xiph

    提供的高效率的固定点Ogg解码库,可以通过VorbisDecoder类使用它。最后,还有一个对轻量级和优秀的kissfft本地

    库的封装,它让你拥有进行一系列fourier音频变换的能力,封装本地代码库的类是KissFFT。像往常一样,在这些类

    的实例不再使用的时候释放掉。 

        音频包以及子包中有很多帮助类,他们可以减轻你编写音频处理类的痛苦。

    5、文件模块(Files Module

         有时,你需要装载你的图片或者音频文件,这时就该文件模块出场了。现在,如果你曾经做过任何Android开发,

    那么你就会知道标准的Java规则不总是适用,特别是那些没有将数据存储在应用的asset文件夹下的情况。在Android

    中,你有多个预定义的文件夹,比如assets,drawables等,你可以将文件放置在其中。然而,你也可以使用Java的类

    路径(例如getResourceAsStream()),Android也支持,不过有点不稳定。我们因此决定采用别的方法来读取或者写

    入文件,输入文件类型。

    文件模型有三种不同的文件类型:

    <1> Internal files(内部文件类型):这些文件将和你的应用程序绑定在一起,在桌面PC环境中,它们相对与你的应

    用程序根目录而存在。在Android上他们存储在assets文件夹下,我们本也可以提供一种方法来访问drawable文件夹下

    面的数据,但是最终决定放弃这个想法,因为在这个文件夹中的数据有可能在系统载入文件的时候被改变。作为一个

    游戏开发人员,我们希望对我们的文件完全控制, Internal files是只读的;

    <2> External files(外部文件):这些文件有可能从服务器上下载来的,或者是你创建用来保存高分辨率或者设置,

    在桌面环境下,它们将相对于当前使用者的home目录存储。在Android下,它们将相对于SD卡根目录存储,

    External files是可读、可写的;

    <3> Absolute files(绝对文件):如果你想体验一下痛苦,你可以使用绝对文件,它不是平台无关的,如果你使用绝

    对路径,那么它只能在你的电脑中运行, Absolute files是可读、可写的。

         文件接口为你提供多种方法去获取FileHandles,文件读写,列出目录等等。同其它模块的交互也经常使用

    FileHandles,有时,输入/输出留也是可以做到的。

         标准Java规则适用于处理文件,你需要关闭任何一个你打开的输入/输出流。如果其它模块占用着FileHandle,它

    将为你自行处理所有的清理工作,因此你不必担心这些。

    6、输出模块(Input Module)

        整个体系的最后一个模块是输入模块,顾名思义,它为你提供了访问应用程序运行平台的输入外设的能力,我们把

    这些东西统一到了一起。

        鼠标和触摸屏是相似的外设,我们采用触摸屏模式,所以在输入模块中没有鼠标事件,有的只是拖动,按下和弹

    起。在触摸屏中经典的鼠标事件(比如,你没有按下一个按钮,只是光标在上面滑过)是不存在的,因为把手指悬停

    坐在屏幕上空并且滑过整个屏幕不产生任何输入事件。基于同样的原因,我们不区分鼠标按钮,在这个模块中仅有一

    个单键的鼠标按钮,但是我们识别触摸屏上的多点触摸事件。在桌面PC环境中,显然只有单个触摸,在Android上

    面,这个问题有点复杂:很多老的设备不支持多点触摸,但是,虽然一些新的设备比如Motorola Droid或者是Nexus

    One支持多点触摸,却没有多手指追踪,(是的,这是真的),你可以通过Files接口查询是否支持多点触摸。如果是支

    持,那么这个设备要么支持多点触摸手势,要么是支持多手指追踪,记住这一点区别。

        多数Android设备同样没有专门的键盘,不要担心,我们没有去掉键盘支持,但是我们是这样做的:我们认为你在

    所有设备上只需要处理一套键码,然后有一些键只能在特定设备中才有,比如Android的back、home、search、

    menu键在桌面PC上是见不到的,轨迹球事件也被当作键盘事件处理。

    我们将触摸屏事件和键盘事件归为两类:

    <1> Polling access(轮询访问):只是询问Input接口是否有键按下或者触摸屏被触摸;

    <2> Event based handlind(事件驱动):你可以使用Input接口注册一个InputListener,它将接收所有的触摸屏和键

    盘事件,这些事件通常在UI线程中处理。在以前的文章中我们提到的,是在渲染线程中,为了减轻使用的难度,我们

    设定InputListener只有在渲染线程中才能被调用(事实上是在RenderListerner.render()方法被调用之前)。

        最后,Android上面的重力感应设备同样在桌面PC上面是没有的,我们设定的该事件处理方式或许有些愚蠢,因为

    它总是产生事件,因此你只可以使用轮询方式来访问当前的重力感应设备的状态。

        注意:我们处理多点触摸的方法略逊于Android的多点触摸API,在Input接口中处理所有与处理InputListenner接口

    中触点索引的触摸事件是一样的,第一个触摸到屏幕的手指标记为触点0,第二个触点为1...以此类推,某个手指的触

    点索引将与其在触摸屏上停留的时间相同。No bit-masking,no other mojo involved。   

        给使用多点触摸的人一句话:小心使用,如果你准备在只提供多点触摸手势检测的设备上实现在其屏幕上利用自定

    义的按钮做一个屏幕方向键盘,这将会导致莫名其妙的结果(这句话的意思就是不要在只支持多点手势检测的设备上

    做多点手势追踪的事情)。在设备上测试你的输入方法,看看是不是有上述问题,确保它们以你预计的方式工作,用

    户将会感谢你的。

    7、网络模块(NetWork Module)

         我们决定不在Libgdx中包含网络模块,值得庆幸的是Android支持大多数标准Java scoket API(有一些轻微的偏

    差,如果你偶然碰到,你也很少会去注意)。如果你想创建一个多人游戏,目前你只能自己编写网络代码,我们将会

    在未来某个时间加入一些,但是别抱太大希望,在网上找一些兼容的Java游戏网络库吧!(可以看LGame里面的网络

    库,那个里面有的)

    8、其他模块(Other Module)

    在Libgdx中除了一些标准模型外,也包含一些对你游戏编程很有用的包:

    <1> math(数学):这个包中有良好的的三角函数查找表,矩阵、四元数、向量类、样条(是指通过一组给定点集来生成平滑曲线的柔性带,此概念源于生产实践,“样条”是绘制曲线的一种绘图工具,是富有弹性的细长条,绘图时用压铁使样条通过指定的形值点(样点),并调整样条使它具有满意的形状,然后沿样条画出曲线)平截头体,多边形三角测量,相交代码,虽然这些方法都很高效,但是碰到如果计算量很大的话,还是考虑一下直接使用本地代码吧(JNI);

    <2> box2d(一个物理引擎):用Java JNI写了一个完全的Box2D,这样做的原因是:

    ①它比用JBox2D快多了;

    ②它不会产生让垃圾收集器发疯的垃圾,其他的项目比如:Rokon或者Andengine都使用了这一部分代码。

    <3> scene2d:一个好的2D场景地图API,它很灵活,适合拿来做UI;

    <4>utils:包含了一些辅助类,大多数是为了减少垃圾收集器的工作,比如自定义的hashmap实现,对象池等等。包中还把JSON库包含了进来,如果你想要读取或者写这个类型的文件,那你就以他们的格式提供材料。

           这一篇差不多总结到这里就要结束了,从下一篇开始,博主会带领大家进入到精彩的

    游戏世界中,让大家慢慢的体会到开发游戏的快乐。

  • 相关阅读:
    CTSC2018滚粗记
    HNOI2018游记
    NOIWC 2018游记
    PKUWC2018滚粗记
    HNOI2017 游记
    NOIP2017题解
    [HNOI2017]抛硬币
    [HNOI2017]大佬
    NOIP难题汇总
    [NOI2013]树的计数
  • 原文地址:https://www.cnblogs.com/dyllove98/p/3138753.html
Copyright © 2020-2023  润新知