跳至正文

世界生成第一节:生成小建筑

作为一个沙盒游戏,泰拉瑞亚的探索内容是游戏的核心游玩项之一,所以这次就来教一下世界生成吧!

本节会介绍一些初级的内容,只包含在地狱放置一个这样的小建筑。


准备工作

在开始之前,先来介绍一下各种常用内容。

Vector2.ToTileCoordinates():将世界坐标转变为图格坐标
(实际上就是除以16再变成Point ,泰拉一格物块的宽高为16像素)


泰拉瑞亚的物块全部存储在Main.Tile[] 中,可以使用坐标获取到物块,示例如下。

Tile t1 = Main.tile[100, 100]; //需要填入物块坐标
Point p = new Point(200, 200);
Tile t2 = Main.tile[p];

Tile:泰拉瑞亚中的每一格物块都是一个Tile,以下是这个东西里面的各种属性与一些常用方法
最好简单浏览一遍每个都认识一下是什么,记不住也无所谓回来再看或查阅源码就行

Tile.TileType物块类型,注意为0并非是空物块,而是泥土
Tile.HasTile用于判断是否有物块
Tile.WallType墙壁类型,与物块不一样,为0表示没有墙壁
(瑞德的神奇小代码)
Tile.IsActuated用于判断是否被制动器虚化
Tile.HasActuator用于判断是否有制动器
Tile.HasUnactuatedTile是否有未虚化的物块(世界生成时基本用不到,一般是弹幕NPC之类的会用)
Tile.Slope物块的斜坡类型,使用SlopeType判断
SlopeType.Solid 实心物块
SlopeType.SlopeDownLeft ◺
SlopeType.SlopeDownRight ◿
SlopeType.SlopeUpLeft ◸
SlopeType.SlopeDownLeft ◹
使用WorldGen.SlopeTile()设置斜坡
另外WorldGen.PoundTile()也可以,但是这个是和锤子一样的方式循环设置斜坡,也会敲出半砖
Tile.TopSlope
Tile.BottomSlope
Tile.TopSlope
Tile.LeftSlope
这几个比较绕,建议看图
例如TopSlope是指没有顶部的斜坡类型
Tile.BlockType方块类型,包括斜坡和半砖,就不具体罗列了
Tile.IsHalfBlock物块是否是半砖形态
Tile.TileColor
Tile.WallColor
物块和墙壁的涂料,使用WorldGen.paintTile()和WorldGen.paintWall()设置这两个。注意这东西并不是Color,而是一个int,请使用PaintID
Tile.LiquidAmount液体量,为0~255
Tile.LiquidType液体类型,建议使用LiquidID判断,为0是水
4种电线
Tile.ClearEverything()清除这一格上的所有东西,包括物块,墙壁,电线,颜料和涂层,液体等
Tile.ClearTile()只清除这一格上的物块和半砖斜坡
Tile.ResetToType()相当好用的东西,可以直接将当前物块设置为指定的类型

ShapeData:本期教程的核心内容之一,它存储了一个形状。具体点说是内部存了与原点相对位置的一个点集。
例如下面这个小小小正方形,它就可以用4个点来描述。

泰拉的物块坐标是物块左上角的点

搭建建筑物

为了调试方便,先用一个物品来放置地形,并且以鼠标位置为原点,参考代码如下。

public override bool CanUseItem(Player player)
{
    Vector2 myVector = Main.MouseWorld;
    Point p = myVector.ToTileCoordinates();

    GenerateExampleStructure(p);
}

public static void ExampleStructure(Point origin)
{
    //之后所有的代码都会放到这个方法里
}

WorldUtils.Gen()

WorldUtils.Gen 是我们将会用到的核心方法,它有3个参数,第一个参数origin 指的是生成的原点。第二个参数shape 是生成的形状,与上面介绍过的ShapeData 略有不同,具体的下面再说。第三个参数action 是指各类行为,例如放置物块和对形状做一些改动等。

下面那个只有2个参数的方法其实和上面的只是写法不同而已

生成圆与矩形

接着就来实战一下吧,首先是生成一块圆形的物块,需要使用到一个叫做Shapes 的类,里面有很多形状,圆形就用Shapes.Circle,直接new一个出来就行,并且需要传入半径。

而行为需要用到Actions,里面也是各种行为,使用Actions.PlaceTile 来放置物块,同样也是直接new一个出来就行,还需要传入物块类型。

//为了看得更清楚所以多换了几个行
//这一段的意思是:以origin(传入的鼠标位置)为圆心,半径20格的区域内放置珊瑚石块
WorldUtils.Gen(
    origin,
    new Shapes.Circle(20),//这个圆的半径为20
    new Actions.PlaceTile(TileID.Coralstone));//放置珊瑚石块

生成效果如下,可以看到玩家所在位置还不会放置物块(

好大一个圆

圆形试过了就来试试矩形吧,原理是一样的,这次要用的是Shapes.Rectangle
与圆形不同,它有两个构造。


只传入宽高的构造会把origin 看作左上角,而直接传入一个矩形的就自由一些。

先来看看只传入宽高的效果。

WorldUtils.Gen(
    origin,
    new Shapes.Rectangle(10, 15),//一个宽10格,高15格的矩形
    new Actions.PlaceTile(TileID.Coralstone));

接下来试着将origin 作为矩形中心来生成。

int width = 10;
int height = 15;
//实际上就是左移宽度的一半再上移高度的一半
WorldUtils.Gen(
    origin,
    new Shapes.Rectangle(new Rectangle(-width / 2, -height / 2, width, height)),
    new Actions.PlaceTile(TileID.Coralstone));

Shapes 中还有其他形状,比如半圆,史莱姆型,小山型,这些你可以自己尝试一下,在文章末尾会稍微介绍一下它们。


清除物块

可能你已经发现了,Actions.PlaceTile 不能在已有物块的位置放置物块。
使用Actions.ClearTile 清理图形内的物块,例如这样就可以先清除范围内的物块并放置新的物块。

(好吧其实这样子用 ClearTile 只是为了教学和为后面的步骤做准备,真需要在有物块的地方再放请使用SetTile

WorldUtils.Gen(
    origin,
    new Shapes.Circle(10),
    new Actions.ClearTile());//先清除
WorldUtils.Gen(
    origin,
    new Shapes.Circle(10),
    new Actions.PlaceTile(TileID.Coralstone));//再放物块

这样倒是可以实现效果,但是这两个东西的区别只有行为不同,能否将他们合并到一起呢?

有的,兄弟有的,使用Actions.Chain 就可以将多个行为变成一条行为链,写法如下。

WorldUtils.Gen(
    origin,
    new Shapes.Circle(10),
    Actions.Chain(
        new Actions.ClearTile(),
        new Actions.PlaceTile(TileID.Coralstone)
        )
    );

”雕刻“图形

要实现本期教程的目标效果,只靠简单的图形是不够的,需要对图形进行一些处理。接下来了解一下如何生成这样一个半圆形的碗状结构,可以尝试思考一下如何生成它。


初次尝试就直接公布答案了,流程如下图所示。


接下来是代码部分。

第一步:还记得准备工作中提到的ShapeData 吗,现在需要用到它来记录图形了。

//同样是直接new一个出来
ShapeData circleData = new ShapeData();

//这里是清除圆形范围内的物块,并且使用GenAction提供的Output方法记录图形
WorldUtils.Gen(
    origin,
    new Shapes.Circle(20),
    new Actions.ClearTile(frameNeighbors: true).Output(circleData));

第二步与第三步:首先使用ModShapes.OuterOutline 获取刚才圆形的外边缘,之后用Modifiers.RectangleMask 裁掉上半部分的圆,最后使用Modifiers.Expand 扩展并放置物块。ModifiersActions 在用法上类似,里面包含了各种对图形的操作。

WorldUtils.Gen(
    origin,
    new ModShapes.OuterOutline(circleData), //刚才生成的圆的外边缘
    Actions.Chain(
        new Modifiers.RectangleMask(-40, 40, 0, 40),//一个矩形,可以想一下这个矩形大概的样子,40只是随便填的
        new Modifiers.Expand(1),//向四周扩展1格
        new Modifiers.IsEmpty(),//找到只有没有物块的地方,虽然不是很有必要可以不写这个
        new Actions.PlaceTile(TileID.HellstoneBrick)));//放置狱石砖

尝试时间!

学过了上面的那些东西,接下来自己尝试生成中间这个灰烬块吧!效果如下所示。

这是一个宽13高26的矩形
请自己尝试过后再来看下面的代码!

ShapeData ashRectData = new ShapeData();

int width = 13;
int height = 26;
//新的中心点,这么写只是为了方便
Point origin2 = new Point(origin.X, origin.Y + height / 3);

WorldUtils.Gen(
    origin2
    new Shapes.Rectangle(new Rectangle(-width / 2, -height / 2, width, height)),
    Actions.Chain(
        new Actions.SetTile(TileID.Ash),
        new Actions.SetFrames(frameNeighbors: true).Output(ashRectData)));//同样记录一下后面会用

接着在灰烬块的内边缘放上一圈灰烬草,需要用到ModShapes.InnerOutline,以及灰烬草的ID是TileID.AshGrass,同样自己尝试一下吧,相信你能做到!

这很灰烬草
代码部分,不要偷看哦~

WorldUtils.Gen(
    origin2,
    new ModShapes.InnerOutline(ashRectData),//用之前记录的形状
    Actions.Chain(
        new Modifiers.IsSolid(),//判断一下放置万一
        new Actions.SetTile(TileID.AshGrass),//使用Set强制放置物块,不用清理后在放了
        new Actions.SetFrames(frameNeighbors: true)));

拼接图形与液体放置

如何精准优雅地在半圆内填入岩浆呢?其实有两种办法,为了教学需要,会使用比较麻烦的方法,之后也会给出另一种简单方法。

右边在灰烬块内也放岩浆了,这不好

ShapeData.Subtract 使用这个减去另一个图形,三个参数分别是另一个ShapeData,自身原点,被减图形的原点。

思路图,按照这个写代码
//注意!circleExpandData是在放置狱石砖的那一步记录的,请自行添加

//减去灰烬块矩形
circleData.Subtract(ashRectData, origin, origin2);
//减去下面的狱石砖半圆形状,注意原点别填错了
circleData.Subtract(circleExpandData, origin, origin);

WorldUtils.Gen(
    origin,
    new ModShapes.All(circleData),//ModShapes.All和字面意思一样,使用完整的图形并非外边缘什么的
    Actions.Chain(
        new Modifiers.RectangleMask(-40, 40, 0, 40),//同样的矩形蒙版
        new Actions.SetLiquid(LiquidID.Lava)));//SetLiquid直接设置液体

简便方法

//不用什么花里胡哨的图形相减,直接判断没有物块的地方放岩浆就完事了
WorldUtils.Gen(
    origin,
    new ModShapes.All(circleData),
    Actions.Chain(
        new Modifiers.RectangleMask(-40, 40, 0, 40),
        new Modifiers.IsEmpty(),
        new Actions.SetLiquid(LiquidID.Lava)));

装饰建筑物

其实经过上面的一通操作,这个中间有个灰烬块的“狱石碗”已经可以算是一个小地形了,但是还是太空洞了,现在来给它加点料。

WorldGen.PlaceObject

使用这个东西来放置多格物块。参数xy 不必多说,就是坐标。type 是物块类型,mute 是指是否静音,在世界生成中一般是true,不然在生成的时候就能听到一堆奇怪的声音,以及播放声音是比较耗时的操作。

style 是物块样式,alternate 是物块的摆放样式,例如放在地板上和放在天花板上,不过大部分物块没这个东西。random 是随机外观,direction 是放置方向。这些东西可以看一下物块教程,不是本期重点。


我们的目标是在灰烬草上放一个肉山圣物出来,所以先找到圣物的物块ID和对应的物块样式。

你很幸运,我已经帮你找好了,ID是TileID.MasterTrophyBase,样式是6。下次记得自己去wiki查还有看贴图。

圣物得看额外贴图才能知道样式

放个圣物的事,一行搞定。

WorldGen.PlaceObject(origin2.X, origin2.Y - height / 2 - 1, TileID.MasterTrophyBase, true, 6);

那么问题来了,上面的这个坐标是如何确定的呢,多格物块的放置原点比较难以说明,大概如下图所示。

并非巧合

效果如下,针不戳吧。


自定义Action

主播主播,原版给的Action 还是太有限了,能不能自己写?我说有的有的,只要继承GenAction 并实现一个方法就行。

下面就是一个我写的Action,它的用处是在灰烬草上面种一个灰烬草植物。

public class ActionAshGrass : GenAction
{
    //这个东西对于图形中的每一个位置都会调用一次
    //当然如果在调用链内,点集还会受到在此之前的其他操作的影响
    public override bool Apply(Point origin, int x, int y, params object[] args)
    {
        //这个_tiles引用的就是Main.tile
        //如果自身这一格没有物块或者顶上的一格有物块就跳过
        if (!_tiles[x, y].HasTile || _tiles[x, y - 1].HasTile)
            return false;

        //下面这个是我用来测试的代码
        //Dust d = Dust.NewDustPerfect(new Point(x, y).ToWorldCoordinates()
        //    , DustID.Torch, Vector2.Zero, Scale: 5);
        //d.noGravity = true;

        //放置灰烬草,PlaceTile的参数和PlaceObject类似
        //但是PlaceTile更适合用来放单格物块,虽然放多物块也行就是不太好用
        //具体参数是干什么的看一下它的注释吧
        WorldGen.PlaceTile(x, y - 1, TileID.AshPlants, mute: true);

        //要用这个哦,不然输出图形会出问题
        return UnitApply(origin, x, y, args);
    }
}

下面来用一下它。

WorldUtils.Gen(
    origin2,
    new ModShapes.All(ashRectData),//使用灰烬块的那个形状
    Actions.Chain(
        new Modifiers.OnlyTiles(TileID.AshGrass),//只作用于灰烬草方块
        new ActionAshGrass()));

放置墙壁

逻辑基本一样,不多介绍了快速过一下。

WorldUtils.Gen(
    origin,
    new ModShapes.All(circleData),
    new Actions.PlaceWall(WallID.HellstoneBrick));//放置狱石墙壁

真正的世界生成

之前一直是通过一个物品来放置地形,接下来要真正的在世界生成中插入这个小建筑的生成了。

首先写一个继承ModSystem 的类,然后重写下面这个方法。

public class MyWorldSystem : ModSystem
{
    public override void ModifyWorldGenTasks(List<GenPass> tasks, ref double totalWeight)
    {
    
    }
}

方法提供的tasks 就是一个“世界生成步骤”的列表,可以查找指定名称的生成步骤然后把自己的生成步骤插入其中。

至于如何找到原版生成步骤的名字可以查看源码的WorldGen 这个类或者看这个视频

先给我们用来生成结构的方法改一下,如下。

//progress内可以修改生成时下面的那个进度条还有显示的名称
public static void ExampleStructure(GenerationProgress progress, GameConfiguration configuration)
{
    //先临时随便写一个origin,大概是在地狱的位置
    //Main.maxTilesY是当前世界的高度,以格为单位
    //与之对应的还有Main.maxTilesX
    Point origin = new Point(100, Main.maxTilesY - 80);
    //下面是刚刚写的那一堆东西,这里就省略了
    //xxxxxxxxxx
}

之后将其插入生成步骤列表中。

//在ModifyWorldGenTasks中写这些

//为什么是设置液体呢,因为它在生成地狱之后并且平衡了一次液体后能避免一些问题
int settleLiquids = tasks.FindIndex(genpass => genpass.Name.Equals("Settle Liquids"));
if (settleLiquids != -1)
{
    //我的测试用物品叫做WorldGenTester
    //建议还是把ExampleStructure这方法直接挪到这个system类里面
    tasks.Insert(settleLiquids + 1, new PassLegacy("MyStructure"
        , WorldGenTester.ExampleStructure));
}

生成一个新的世界,就可以看到地形成功生成了!


选取更好的位置

生成是生成出来了,就是这样埋进地里的效果有点过于搞笑了,还是尽量让它露出表面吧。

这就涉及到一个问题,如何找到地狱的表面?在之后的地形制作中你会经常遇到类似的问题。
对于这个问题我的想法是从下向上遍历查找,直到找到一个上方有足够多空间的地方,代码如下。

//随机选择X位置,最小80是为了防止出地图或是触碰世界边界导致报错
//不取太大是因为可能会和地狱堡垒相撞,当然你可以自己修改最大值试试
//别超出Main.maxTileX就问题不大
int x = Main.rand.Next(80, 300);
int y = 200;

//地狱就200格高
for (int i = 30; i < 200; i++)
{
    //获取物块
    //更加好的写法是用Framing.GetTileSafely
    //不过世界生成里只要逻辑没问题一般不会越界
    Tile t = Main.tile[x, Main.maxTilesY - i];

    //如果当前位置有实心物块或者有液体(判断一下岩浆)就继续想上找
    if ((t.HasTile && Main.tileSolid[t.TileType]) || t.LiquidAmount > 0)
        continue;
    
    //对上方小范围进行检测,如果上方都是空的那么就说明找到了表面
    //你可能会想:就这么几格够判断吗
    //一般来说都是够的,如果不够就自己加的多一点就行了
    bool empty = true;
    for (int j = 1; j < 8; j++)
    {
        Tile t2 = Main.tile[x, Main.maxTilesY - i - j];
        if ((t2.HasTile && Main.tileSolid[t2.TileType]) || t2.LiquidAmount > 0)
        {
            empty = false;
            break;
        }
    }
    
    //如果成功找到一个没有物块并且顶部一条也是空的位置就跳出循环
    if (empty)
    {
        y = i;
        break;
    }
}

Point origin = new Point(x, Main.maxTilesY - y);

游戏内效果,还挺不错!


尾声

本期只介绍了一小部分的ActionModifier,建议自己把里面所有的东西都尝试一遍了解一下都是干什么的。

这是原版的附魔剑冢的生成,包括了一些本期教程中没有介绍过的东西,不过看一看应该也能看得懂

附魔剑冢代码

public bool Place(Point origin, StructureMap structures)
{
    //使用TileScanner,检查以原点为中心的50x50区域是否大部分是泥土或石头。
    Dictionary<ushort, int> tileDictionary = new Dictionary<ushort, int>();
    WorldUtils.Gen(
        new Point(origin.X - 25, origin.Y - 25),
        new Shapes.Rectangle(50, 50),
        new Actions.TileScanner(TileID.Dirt, TileID.Stone).Output(tileDictionary));

    //如果数量少于1250,则返回false,会重新选取一个原点。
    //(这个方法外如何取的原点可以查看源码)
    if (tileDictionary[TileID.Dirt] + tileDictionary[TileID.Stone] < 1250)
        return false; 

    Point surfacePoint;
    //向上搜索1000格,找到50格高、1格宽的没有实心物块的区域。基本上等于找到了地表。
    //这个WorldUtils.Find就是教程中没讲的东西(教程里直接硬写的检测)
    bool flag = WorldUtils.Find(
        origin,
        Searches.Chain(new Searches.Up(1000),
        new Conditions.IsSolid().AreaOr(1, 50).Not()), out surfacePoint);

    //从原点到地表进行搜索,确保之间没有沙子。
    if (WorldUtils.Find(
        origin,
        Searches.Chain(new Searches.Up(origin.Y - surfacePoint.Y),
        new Conditions.IsTile(TileID.Sand)), out Point _))
        return false;

    if (!flag)
        return false;

    surfacePoint.Y += 50;
    ShapeData slimeShapeData = new ShapeData();
    ShapeData moundShapeData = new ShapeData();
    Point point = new Point(origin.X, origin.Y + 20);
    Point point2 = new Point(origin.X, origin.Y + 30);
    float xScale = 0.8f + WorldGen.genRand.NextFloat() * 0.5f;
    //随机剑冢地形的宽度
    //检查剑冢的肺结构和结构图是否有冲突
    //这个结构图是GenVars.structures,其中记录了大部分受保护地形如蜂巢等,可以判断它来防止冲突
    if (!structures.CanPlace(new Rectangle(point.X - (int)(20f * xScale), point.Y - 20, (int)(40f * xScale), 40)))
        return false;
    //通往地面的竖井检查结构图中是否有任何冲突
    if (!structures.CanPlace(new Rectangle(origin.X, surfacePoint.Y + 10, 1, origin.Y - surfacePoint.Y - 9), 2))
        return false;


    //使用史莱姆形状清理物块。Blotches作用是让边缘随机起伏。 https://i.imgur.com/WtZaBbn.png
    //上面的网站是tml教程里就有的
    WorldUtils.Gen(
        point,
        new Shapes.Slime(20, xScale, 1f),
        Actions.Chain(
            new Modifiers.Blotches(2, 0.4),
            new Actions.ClearTile(frameNeighbors: true).Output(slimeShapeData)));


    //在切出的史莱姆形状内放置一个土堆。
    WorldUtils.Gen(
        point2,
        new Shapes.Mound(14, 14),
        Actions.Chain(
            new Modifiers.Blotches(2, 1, 0.8),
            new Actions.SetTile(TileID.Dirt),
            new Actions.SetFrames(frameNeighbors: true).Output(moundShapeData)));


    //史莱姆形状减去小土堆形状,得到一个类似肺的形状
    slimeShapeData.Subtract(moundShapeData, point, point2);


    //沿着史莱姆形状的内边缘放置草方块
    WorldUtils.Gen(
        point,
        new ModShapes.InnerOutline(slimeShapeData),
        Actions.Chain(
            new Actions.SetTile(TileID.Grass),
            new Actions.SetFrames(frameNeighbors: true)));


    //在史莱姆形状的下半部分的空位置上放水
    WorldUtils.Gen(
        point,
        new ModShapes.All(slimeShapeData),
        Actions.Chain(
            new Modifiers.RectangleMask(-40, 40, 0, 40),
            new Modifiers.IsEmpty(),
            new Actions.SetLiquid()));


    //在所有史莱姆形状内放花墙。在史莱姆形状的所有草方块下放置藤蔓。
    WorldUtils.Gen(
        point,
        new ModShapes.All(slimeShapeData),
        Actions.Chain(
            new Actions.PlaceWall(WallID.Flower),
            new Modifiers.OnlyTiles(TileID.Grass),
            new Modifiers.Offset(0, 1),
            new ActionVines(3, 5)));


    //向上打竖井,并且将沙子变为硬化沙子防止它掉下来
    ShapeData shaftShapeData = new ShapeData();
    WorldUtils.Gen(
        new Point(origin.X, surfacePoint.Y + 10),
        new Shapes.Rectangle(1, origin.Y - surfacePoint.Y - 9),
        Actions.Chain(
            new Modifiers.Blotches(2, 0.2),
            new Actions.ClearTile().Output(shaftShapeData),
            new Modifiers.Expand(1),
            new Modifiers.OnlyTiles(TileID.Sand),
            new Actions.SetTile(TileID.HardenedSand).Output(shaftShapeData)));


    //设置物块帧
    WorldUtils.Gen(
        new Point(origin.X, surfacePoint.Y + 10),
        new ModShapes.All(shaftShapeData),
        new Actions.SetFrames(frameNeighbors: true));


    //有三分之一放置一个真附魔剑冢
    if (WorldGen.genRand.NextBool(3))
        WorldGen.PlaceTile(point2.X, point2.Y - 15, TileID.LargePiles2, mute: true, forced: false, -1, 17);
    else
        WorldGen.PlaceTile(point2.X, point2.Y - 15, TileID.LargePiles, mute: true, forced: false, -1, 15);
    //在草方块上种植物。
    WorldUtils.Gen(
        point2,
        new ModShapes.All(moundShapeData),
        Actions.Chain(
            new Modifiers.Offset(0, -1),
            new Modifiers.OnlyTiles(TileID.Grass),
            new Modifiers.Offset(0, -1), new ActionGrass()));
    //将剑冢添加到结构图中,防止后生成的其他东西和剑冢冲突。
    structures.AddStructure(new Rectangle(point.X - (int)(20f * xScale), point.Y - 20, (int)(40f * xScale), 40), 4);
    return true;
}

世界生成需要相当多的实践,肯花时间研究还有多寻思的话迟早能做出下面这样的地形,它们都大量使用了本期教学中的内容。

发表回复