voxel_engine 0.4.0-dev copy "voxel_engine: ^0.4.0-dev" to clipboard
voxel_engine: ^0.4.0-dev copied to clipboard

The pure-Dart half of the voxel kit: chunk grid, meshing, streaming, physics, world generation, block and item content, signals and a TCP net layer.

voxel_engine #

The engine under a voxel game, in pure Dart: a chunk grid, meshing with light and ambient occlusion, streaming on worker isolates, physics, rays, world generation, blocks and items, circuits and a network layer. It draws nothing — a renderer (voxel_scene) takes the meshes, and voxel_game ties everything into a playable game.

Status: 0.4.0-dev, beta. The API can still change.

The five subjects #

Each one is a library of its own. Import only what you use, or package:voxel_engine/voxel_engine.dart for all of it.

Library What it does Depends on
core.dart chunk grid, mesher, streaming, physics, rays, liquids, pathfinding, the edit save format —
worldgen.dart biomes, terrain, caves, ores, trees, structures, as a declared spec core
content.dart blocks and items by string id, mining, inventory, crafting, loot, effects core
signals.dart wires, levers, lamps, doors, pistons, and rails that lay themselves core
net.dart a TCP host and its clients, exchanging JSON messages —

Install #

dependencies:
  voxel_engine: ^0.4.0-dev

Dart SDK ^3.13.0. No Flutter dependency: it runs in dart test, on a server and on worker isolates. Not on the web: streaming spawns isolates and net.dart is TCP sockets, through dart:isolate and dart:io, which a browser does not have.

Usage #

core — a world you can walk in #

  1. Describe your blocks. Id 0 is air.

    final table = VoxelBlockTable(const [
      VoxelBlockDef(shape: BlockShape.cube, solid: false, opaque: false, r: 0, g: 0, b: 0, a: 0), // air
      VoxelBlockDef(shape: BlockShape.cube, solid: true, opaque: true, r: 0.4, g: 0.4, b: 0.45),  // stone
    ]);
    
  2. Write a generator. It fills one chunk and must be pure in the position.

    class Flat implements ChunkGenerator {
      const Flat();
      @override
      Uint8List generateIn(int cx, int cz, int dimension) {
        final blocks = Uint8List(ChunkSize.volume);
        for (var z = 0; z < ChunkSize.sizeZ; z++) {
          for (var x = 0; x < ChunkSize.sizeX; x++) {
            for (var y = 0; y < 40; y++) {
              blocks[ChunkSize.index(x, y, z)] = 1;
            }
          }
        }
        return blocks;
      }
    }
    
    ChunkGenerator makeGenerator() => const Flat(); // top-level: sent to each isolate
    
  3. Stream chunks around a point. The sink receives each finished mesh.

    final pool = ChunkWorkerPool(ChunkWorkerConfig(generator: makeGenerator, table: table));
    await pool.start();
    final streamer = ChunkStreamer(table: table, sink: mySink, loadRadius: 4)
      ..jobs = pool
      ..updateAround((x: 0, z: 0));
    // Once a frame:
    streamer.update();
    
  4. Read and edit the world. streamer.getBlockXYZ, streamer.setBlock, VoxelRaycast.solid(...) for aiming, VoxelBody for anything that walks.

  5. Save only the edits with EditDeltaCodec; the generator rebuilds the rest.

content — blocks, items and what the player carries #

  1. Declare blocks. Air first; the order numbers them for the engine, and blocks.table is the VoxelBlockTable step 1 above asked for. A row also says what the block does, for a game to act on: it falls, it needs a support (Support.below, Support.side; blocks.stands answers whether it would stay), it has an onWall form, it drops a rolled loot, it turns to face the placer (Facing.compass, Facing.axis), it is two cells tall, a use turns it into another (usedInto), it grows into its next stage (a Growth), a tool turns it into another (turnsWith), it stores things (a Storage of slots, and the loot a generated one holds).

    final blocks = BlockRegistry(const [
      BlockType('air', color: 0, solid: false, hardness: -1, drop: ''),
      BlockType('stone', color: 0x7F7F84, hardness: 1.5, tool: 'pickaxe', tier: 1, drop: 'cobblestone'),
      BlockType('cobblestone', color: 0x6E6E70, hardness: 2.0, tool: 'pickaxe'),
    ]);
    
  2. Declare items. Every block you can hold is an item already (a block only the world makes says holdable: false). One that is eaten says what it does in food (a Food that cures ends the bad effects), one that is worn where in armor, one that carries a liquid in bucket (Bucket.empty, Bucket.full), one that shoots in launcher (Launcher(shot:, ammo:)), one that lights the way in hand in light, one that glides in glider. What it looks like is its shape, or one read off the rest of its row; ItemModel.of(item, blocks, items) builds its voxels.

    final items = ItemRegistry([
      ...ItemRegistry.forBlocks(blocks),
      const ItemType('wooden_pickaxe', color: 0xB08850, tool: 'pickaxe', tier: 1, stack: 1, durability: 60),
      const ItemType('apple', color: 0xD03A2A, food: Food(hunger: 4)),
      const ItemType('wool_tunic', color: 0xE8E8E8, stack: 1, armor: Armor('chest', 3)),
      const ItemType('wool_cap', color: 0xE8E8E8, stack: 1, armor: Armor('head', 1), shape: ItemShape.cap),
    ]);
    
  3. Mine. const MiningRules().mineTime(block, tool) gives seconds, or -1 when that tool cannot break it; a tool whose kind cuts a block's tag takes it at once (shears through leaves).

  4. Carry things.

    final bag = Inventory(stackSize: (id) => items[id].stack, maxDurability: (id) => items[id].durability)
      ..add('cobblestone', 10);
    
  5. Craft.

    final book = RecipeBook(const [Recipe('stone', 1, {'cobblestone': 1}, station: 'furnace')]);
    book.craft(book.available('furnace').first, bag);
    
  6. Roll loot. A LootTable rolls each entry by its own chance (a chest, a mob's drops); a LootTable.oneOf gives one entry or nothing, each chance a slice of one roll (a fishing line's catch). check() throws for slices summing over 1.

    const catches = LootTable.oneOf([LootEntry('raw_fish', 1, 1, 0.7), LootEntry('stick', 1, 2, 0.15)]);
    final got = catches.roll(math.Random()); // raw_fish 70 %, sticks 15 %, nothing 15 %
    

worldgen — a world declared instead of coded #

  1. Give it your block ids. A BlockRegistry already has them: blocks.ids.

  2. Declare the world.

    const world = WorldGenSpec(
      biomes: [
        Biome('forest', top: 'grass', under: 'dirt', climate: Climate.wet,
            trees: [TreeSpec.oak(log: 'log', leaves: 'leaves')], treeChance: 90),
        Biome('plains', top: 'grass', under: 'dirt'),
      ],
      beach: Biome('beach', top: 'sand'),
      ores: [Ore('coal_ore', share: 0.11)],
    );
    
  3. Compile it with a seed and use it as the ChunkGenerator of step 2 above.

    ChunkGenerator makeGenerator() => world.compile(blocks.ids, 2024);
    
  4. Ask it about the world without generating chunks: surfaceHeight(x, z), biomeAt(x, z), structuresNear(cx, cz).

  5. Add structures: a stock one (Dungeon, Tower, Well, Camp, Ruins, Mine, Village, Temple) takes its blocks by name; your own is a build function. Structures keep apart: a site within reach of an earlier one's is dropped. A dungeon's and a mine's parts are where their plan says, so a game finds them without reading the world: DungeonPlan.of(dungeon, site, generator.rollOf(site)).spawners, MinePlan.of(...).chest.

    void tower(StructureSite s) {
      s.level(-2, -2, 2, 2, 'stone', clearTo: 10);
      s.fill(-2, 0, -2, 2, 8, 2, 'stone', hollow: true);
    }
    // structures: [
    //   StructureSpec('well', Well(rim: 'cobblestone', water: 'water', posts: 'fence', roof: 'planks')),
    //   StructureSpec('tower', CustomStructure(tower, radius: 3, blocks: {'stone'}), biomes: ['plains']),
    // ]
    
  6. Dress the ground with rows: strata (dark stone in the deep), a biome's covers (snow above a height, patches of mud), pools (water over mud), trees by weight, and plants that grow taller (maxHeight), in patches (spread) or by water (byWater), low ground pressed toward the sea (flats, a swamp), and biomes of their own on the beach's columns (shores, a frozen one).

  7. Other kinds of world. A cavern (CavernSpec) is one great cave between a bedrock floor and roof; a plaza (Plaza, or withPlaza) is a square of open ground pressed flat, a showroom's floor; a DimensionGenerator hands each dimension's chunks to its own spec, for the worker isolates.

signals — circuits and rails #

  1. Tell the network about every edit. It needs a VoxelEditor, and your world calls touch whenever a block changes:

    @override
    bool setBlock(IVec3 cell, int id) {
      final old = getBlockXYZ(cell.x, cell.y, cell.z);
      // ...write the block...
      signals.touch(cell, old, id);
      return true;
    }
    
  2. Declare the circuit blocks.

    final signals = SignalNetwork(world, SignalRules(
      wireOff: wireOff,
      wireOn: wireOn,
      sources: const {leverOn},
      toggles: const {leverOff: leverOn, leverOn: leverOff},
      reactions: {
        lampOff: SignalReactions.swap(lampOff, lampOn),
        lampOn: SignalReactions.swap(lampOff, lampOn),
      },
    ));
    
  3. Tick it every frame with signals.tick(dt), and call signals.use(cell) when the player clicks a lever.

  4. Rails: name each rail shape once, then place rails through the graph.

    final rails = RailGraph({railNs: const RailVariant('rail', 'ns'), railEw: const RailVariant('rail', 'ew') /* ... */});
    rails.place(world, cell, railNs); // turns to meet its neighbours
    final next = rails.nextCell(world, cell, RailGraph.e);
    

net — two games in one world #

  1. Host.

    final host = await NetHost.bind(port: 7777);
    host
      ..onJoin = ((peer) => print('player ${peer.id} joined'))
      ..onMessage = ((peer, message) => host.broadcast(message, except: peer.id))
      ..onLeave = ((peer) => print('player ${peer.id} left'));
    
  2. Join from another app or machine.

    final client = await connectToHost('192.168.0.10', port: 7777);
    
  3. Talk. Messages are JSON objects; t says what they are.

    client.send({'t': 'pose', 'p': [12.5, 64.0, -3.0]});
    client.listen((message) => print(message['t']));
    
  4. Leave. await client.close(); runs the host's onLeave, and await host.close(); drops everyone.

    On macOS, a Flutter app that hosts or joins needs the com.apple.security.network.server and com.apple.security.network.client entitlements.

Examples #

Six files, each runnable on its own:

dart run example/voxel_engine_example.dart   # the subjects in one run
dart run example/core_example.dart           # streaming, rays, edits, a falling body
dart run example/worldgen_example.dart       # a text map of a four-biome world, then 25 chunks
dart run example/content_example.dart        # blocks, mining, a bag, crafting, loot, effects
dart run example/signals_example.dart        # a lever lights a lamp; a cart rides a rail line
dart run example/net_example.dart            # a host and a client over localhost

example/voxel_engine_example.dart is the one to read first: content declares the blocks, worldgen builds a world out of the same ids, core meshes a chunk and casts a ray into it, and content mines what the ray hit into a bag.

1
likes
160
points
677
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

The pure-Dart half of the voxel kit: chunk grid, meshing, streaming, physics, world generation, block and item content, signals and a TCP net layer.

Homepage
Repository (GitHub)
View/report issues

Topics

#voxel #game-engine #procedural-generation #physics #networking

License

MIT (license)

Dependencies

meta, vector_math

More

Packages that depend on voxel_engine