Game - BenBerke/Tilky-Engine GitHub Wiki

Game

Game is the table for the level as a whole: creating and finding entities, finding the sector at a point, casting rays, changing the sky, and moving to another level.

local player

function Start()
    player = Game.FindEntity("Player")
end
Function Returns
CreateEntity(isUIEntity) Entity Adds a new entity to the level, or a copy of one (prefabs too).
FindEntity(name) Entity or nil The first entity with exactly this name.
FindEntities(name) list of Entity Every entity with exactly this name.
FindEntitiesWithTag(tag) list of Entity Every entity with this tag.
GetEntity(id) Entity or nil The entity with this ID.
GetEntities() list of Entity Every entity in the level.
GetSectorAt(position) Sector or nil The sector at a point on the map.
Raycast(origin, direction, length, ignoredEntityID, requireCollider) table or nil What a line hits first.
LineOfSight(from, to) boolean Whether anything solid is between two points.
LoadLevel(levelName) Switches to another level at the end of this frame.
SetSkyMode(mode), GetSkyMode() SkyMode How the sky image wraps around the camera.
SetSkyTexture(path), GetSkyTexture() string The image for Equirectangular and Cylinder skies.
SetSkyCubemapFace(face, path), GetSkyCubemapFace(face) string One image of a Cubemap sky.
SetSkyRotation(degrees), GetSkyRotation() number Turns the sky.
SetSkyRotationSpeed(degreesPerSecond), GetSkyRotationSpeed() number Keeps the sky turning.
SetSkyTint(color), GetSkyTint() Vector3 Colors the sky image.
SetSkyHorizonOffset(degrees), GetSkyHorizonOffset() number Moves the horizon up or down.
SetSkyFallbackColor(color), GetSkyFallbackColor() Vector3 Shown when there is no usable image.
Property Type
levelName string read-only The current level's name.

CreateEntity

Game.CreateEntity()          -- world entity
Game.CreateEntity(true)      -- UI entity
Game.CreateEntity(enemy)     -- a copy of an entity, or a new instance of a prefab
Parameter Type
isUIEntity boolean, optional true makes a UI entity. Leave it out (or pass false) for a world entity.
entity Entity Instead of a boolean: the new entity is a copy of this one. See Copies and prefabs.

Adds a new entity to the level right away and returns it. It is the same as placing a new entity in the editor:

  • It is named "Entity", has no tags and is enabled.
  • A world entity gets a Transform at the origin. A UI entity gets a UI Transform instead. It has no other components.
  • It gets a new id, so Game.FindEntity, Game.GetEntities and the other lookups find it from now on.

Give it more components with AddComponent, for example "Sprite" to draw it or "Collider" to make it solid. Scripts can't be added, so an empty created entity can't run a script of its own. A copy can (see below).

Copies and prefabs

Passing an entity makes a copy of it, every component and script included, and returns the copy. Its scripts start on the next frame (their Start runs then).

The usual way is a prefab: drag a .prefab from the Asset Browser onto an Entity field, then spawn it:

public Entity enemy -- a .prefab dropped here in the inspector

function Update()
    if Input.GetKeyDown(Key.E) then
        local e = Game.CreateEntity(enemy)
        e.transform.position = entity.transform.position
    end
end

An Entity field holding a prefab gives a copy of the prefab's entity that is not in the level: you can read it (enemy.name, enemy.sprite...) and change it, which changes what this play session spawns from it, but never the .prefab file. The new entity starts where the prefab's own Transform is.

Passing an entity from the level copies it as it is right now, named with Copy added.

Entities created while the game runs are not saved into the level. Stopping the game in the editor removes them.

-- Drop a visible marker where the player is standing.
function Update()
    if not Input.GetKeyDown(Key.M) then return end

    local marker = Game.CreateEntity()
    marker.name = "Waypoint"
    marker.transform.position = entity.transform.position
    marker:AddComponent(Component.Sprite).northTextureFileName = "Textures/flag.png"
end

Finding entities

Game.FindEntity(name)            -- Entity or nil
Game.FindEntities(name)          -- { Entity, ... }
Game.FindEntitiesWithTag(tag)    -- { Entity, ... }
Game.GetEntity(id)               -- Entity or nil
Game.GetEntities()               -- { Entity, ... }
  • Names and tags must match exactly, including upper and lower case.
  • The lists are ordinary 1-based Lua tables. They are empty, never nil, when nothing matches.
  • The result is a snapshot. Entities created or destroyed later don't appear in or disappear from a list you already have, so check e.isValid before using an entity you kept.
  • Every call loops over the whole level. Call them once in Start and keep the result instead of calling them every frame.
  • GetEntity takes the number from entity.id or from a raycast's entityID.
-- Count the enemies left and open the exit when none remain.
public Sector exit = nil

function Update()
    for _, e in ipairs(Game.FindEntitiesWithTag("enemy")) do
        if e.isValid then return end
    end

    exit:MoveCeilingTo(1, exit.floorHeight + 40, 60)
end

(For a real level, find the enemies once in Start instead of every frame.)


GetSectorAt

Game.GetSectorAt(position)   -- Sector or nil
Parameter Type
position Vector2 or Vector3 A Vector2 is a map position (.y is world z). For a Vector3, only x and z are used.

Returns the sector that contains the point, or nil if the point is outside the map. Height is ignored, and if sectors are nested the innermost one wins. It is the same rule that decides which sector an entity is in, so Game.GetSectorAt(e.transform.position) gives the same answer as e:GetSector().

-- Only drop a pickup where there is floor.
local spot = entity.transform.position + Vector3(mathT.RandomF(-32, 32), 0, mathT.RandomF(-32, 32))
local where = Game.GetSectorAt(spot)
if where ~= nil then pickup.transform.position = Vector3(spot.x, where.floorHeight, spot.z) end

Raycast

Game.Raycast(origin, direction, length, ignoredEntityID, requireCollider)   -- table or nil
Parameter Type
origin Vector3 Where the ray starts.
direction Vector3 Which way it goes. It doesn't need length 1. A zero vector never hits anything.
length number How far it reaches.
ignoredEntityID integer An entity the ray passes through, usually the caster's own entity.id. Pass 0 to ignore nothing.
requireCollider boolean See below.

All five arguments are required.

The ray stops at the first thing it hits: a wall, an entity, a floor or a ceiling. If it hits nothing within length, the result is nil. Otherwise it is a table:

Field Type
type string "Entity", "Wall", "SectorFloor" or "SectorCeiling".
typeID integer The same as a number: 1 Entity, 2 Wall, 3 SectorFloor, 4 SectorCeiling.
position Vector3 The point that was hit.
distance number From origin to position.
entity Entity or nil Set when an entity was hit.
wall Wall or nil Set when a wall was hit.
sector Sector or nil Set when a floor or ceiling was hit.
entityID, wallID, sectorID integer The IDs of the above. When nothing of that kind was hit, they hold a large invalid number, so test hit.entity ~= nil rather than the ID.

What the ray can hit

  • Walls: every solid part of a wall, including the steps above and below an opening. The open part of a doorway lets the ray through.
  • Floors and ceilings of every interval of every sector, slopes included: a sloped surface is hit where it actually is, as drawn.
  • Entities, depending on requireCollider:
    • true: only entities with an active, non-trigger Collider. Sphere, capsule and box shapes are all used.
    • false: every entity with a Transform, using a box the size of its scale, standing on its position. Colliders are ignored. This also hits things like invisible markers, so true is usually what you want.

For a ray from a camera through the crosshair, use camera:Raycast([length], [requireCollider]). It starts at the eye (transform.position is at the feet), follows the camera's current yaw and pitch, ignores the camera's own entity, and returns the same table. camera:ScreenToRay(x, y) gives the origin, direction for any other point on screen.

-- Scripts/Shoot.lua (on the player): damage whatever is under the crosshair.
public number damage = 20

function Update()
    if not Input.GetMouseButtonDown(Input.MouseLeft) then return end

    local hit = entity.camera:Raycast(2000, true)
    if hit == nil then return end

    if hit.entity ~= nil then
        local health = hit.entity:GetScript("Health")
        if health.isValid then health:TakeDamage(damage) end
    elseif hit.wall ~= nil then
        Debug.Print("Hit a wall at", hit.position)
    end
end

LineOfSight

Game.LineOfSight(from, to)   -- boolean
Parameter Type
from Vector3 One end of the line.
to Vector3 The other end.

true when nothing solid is between from and to. Walls (their solid parts, the same as for Raycast), floors and ceilings block the line. Entities never do, so there is no entity to ignore. If from and to are the same point, the result is true.

Use it for "can this enemy see the player?" checks. It is much cheaper than calling Raycast again and again to step past every entity standing in between.

-- Scripts/Guard.lua: notice the player when there is a clear line to their eyes.
public Entity player
public number eyeHeight = 12

function Update()
    if player == nil then return end

    local eyes = entity.transform.position + Vector3(0, eyeHeight, 0)
    local target = player.transform.position + Vector3(0, eyeHeight, 0)

    if Game.LineOfSight(eyes, target) then
        Debug.Print("I see you")
    end
end

LoadLevel

Game.LoadLevel(levelName)
Parameter Type
levelName string A level's file name, with or without .bson: "Level2" or "Level2.bson". The file can be in any folder under Assets.

Switches the game to another level. The level is loaded from its saved file, so in the editor, unsaved changes to that level aren't included. Save it before pressing Play if you want them.

When it happens. The call only queues the switch, the same way entity:Destroy() is queued. The rest of the frame carries on in the old level: code after the call still runs, and Game.levelName is still the old name. Once the frame is over:

  1. Every script in the old level gets OnDestroy.
  2. The new level replaces the old one. Its entities, sectors, sounds and sky are all its own, exactly as saved.
  3. The new level starts just like the first one does: its camera and Player Controller are picked, and every script gets OnEnable and Start.

If LoadLevel is called several times in one frame, the last call wins. Calls made from OnDestroy during a switch are ignored. Calls from the new level's Start are fine: they switch again after the new level's first frame.

What carries over. Only the Global table. Everything else belongs to the level. Each level has its own player entity, placed in the editor. Entities, sectors and walls you kept in variables or in Global don't carry over; see Global.

Errors. A name that isn't a string, a level that doesn't exist, or a name that more than one level file has, raises an error at the call, and nothing is queued. If the file exists but can't be read, the error is logged and the game stays on the current level.

In the editor. Pressing Stop always puts you back on the level you pressed Play on, whatever level the game ended on.

Loading happens in one go, so the game may freeze for a moment while a big level loads.

-- Scripts/Flow/Exit.lua (trigger at the end of the level)
public string nextLevel = "Level2"

function OnTriggerEnter(other)
    if other.hasPlayerController then Game.LoadLevel(nextLevel) end
end
-- Restart the current level when the player falls out of the map.
function Update()
    if entity:GetSector() == nil then Game.LoadLevel(Game.levelName) end
end

levelName

Game.levelName   -- "Level2"

The current level's name: its file name in the Levels folder, without .bson. It changes when the new level starts, not when LoadLevel is called. Assigning to it raises an error.


Sky

function Start()
    Game.SetSkyMode(SkyMode.Equirectangular)
    Game.SetSkyTexture("Skies/sunset.png")
    Game.SetSkyRotationSpeed(2)
end

Every level has one sky, drawn behind everything: wherever no wall, floor, ceiling or sprite covers the screen, you see the sky. To open a ceiling to it, make the ceiling see-through (alpha 0 in its color). The sky is set in the editor under World Settings > Rendering > Sky and saved with the level. Changes made from a script last until the level is loaded again, and are undone when you stop playing in the editor.

Mode Images
SkyMode.Cubemap six, set with SetSkyCubemapFace A cube around the camera.
SkyMode.Equirectangular one 2:1 panorama, set with SetSkyTexture A sphere around the camera. The middle of the image is Front.
SkyMode.Cylinder one strip, set with SetSkyTexture DOOM's sky. The image repeats every 90 degrees (a 256 pixel wide image), and its rows are tied to the screen: row 0 at the top, row 100 in the middle, 200 rows top to bottom. Looking up and down doesn't move it, just like in DOOM.

Directions. Front is +Z (where a camera with yaw 0 looks), Right is -X, Left is +X, Up is +Y. SkyFace has Right, Left, Up, Down, Front and Back. Every face is drawn as if you were standing inside the cube looking at it, upright. Up has Front along its bottom edge, Down has Front along its top edge.

-- A cubemap from Assets/Skies/Space.
local faces = {
    [SkyFace.Right] = "right", [SkyFace.Left] = "left", [SkyFace.Up] = "up",
    [SkyFace.Down] = "down", [SkyFace.Front] = "front", [SkyFace.Back] = "back",
}

function Start()
    Game.SetSkyMode(SkyMode.Cubemap)
    for face, name in pairs(faces) do
        Game.SetSkyCubemapFace(face, "Skies/Space/" .. name .. ".png")
    end
end
Setting Unit
Rotation degrees Turns the sky the same way camera yaw turns.
Rotation speed degrees per second Added to the rotation every frame while the game runs. The editor view never spins.
Horizon offset degrees Positive raises the horizon. On Cubemap and Equirectangular skies it moves every point of the sky up by that angle; on a Cylinder sky it is the angle at the middle of the screen.
Tint Vector3, 0..255 Multiplies the image. White (255, 255, 255) leaves it unchanged.
Fallback color Vector3, 0..255 Fills the screen when the sky has no usable image: no texture, a Cubemap with a face missing, or an image that fails to load. It also shows through transparent pixels.
-- Dusk: slowly turn the sky red.
local t = 0

function Update()
    t = math.min(t + GameTime.deltaTime / 10, 1)
    Game.SetSkyTint(Vector3(255, 255 - 135 * t, 255 - 135 * t))
end

SetSkyMode raises an error for a value that isn't in SkyMode, and the cube face functions do the same for a value that isn't in SkyFace. Paths are relative to Assets; an empty string clears the image.