Rooms, portals and line of sight
A Hammerite level is carved, not built: solid rock everywhere, and air brushes cut the rooms out of it. The compiler works out from those brushes which rooms there are and where they open into each other, and a game can ask the same questions its AI needs - which room am I in, where are the doors out of it, can that guard see me. The manual explains the model as an author meets it.
All of it is in map space, the coordinates the brushes are authored in. Under a HammeriteMap3D
that has been moved, convert with map_3d.to_local(world_point) first.
1. Which room
A HammeriteCell is one convex piece of carved air. A room is a group of
them - a pillar splits a hall into four cells that are still one room - and cell.key() names the
room, not the piece:
var cell: HammeriteCell = map.point_in_cell(map_3d.to_local(guard.global_position + Vector3.UP * 64.0))
if cell:
var room: Vector2i = cell.key()
point_in_cell() is null in rock. Ask from the head or the chest rather than the feet: a point
exactly on a room's floor is in no cell.
Cells are derived, and replaced every time the level is compiled. Keep the key, not the cell: the
key is the same room after a recompile, and map.get_region_cells(room) finds its cells again.
What a room is: its properties are those of the air brush that carved it -
map.get_cell_property(cell, &"some/property"). Whether it is indoors has two answers, deliberately
different:
map.cell_reaches_sky(cell)- sky light reaches it, through a window if need be. The lighting question.map.cell_is_open_to_sky(cell)- it has open sky over it. A room with a window onto a courtyard is not: its rain is at the window. The question for weather and acoustics.
2. What joins rooms
A HammeritePortal joins two rooms, in one of two ways:
- an aperture - the rooms abut on a shared wall, and the portal is the hole in it: a doorway, a
window. It has a
polygonand aplane. - an overlap - two air brushes interpenetrate, so they are one continuous space with no wall between to hole. It has neither.
portal.is_aperture() tells them apart. The author's rule that follows: overlap to extend a space,
abut to divide one.
for portal: HammeritePortal in map.get_portals_for_cell(room.x):
var beyond: Vector2i = portal.other_region(room)
if beyond == Vector2i.ZERO or not portal.is_aperture():
continue
_doorways.append({"to": beyond, "name": portal.get_portal_name(), "way": portal.normal_from(room)})
get_portals_for_cell() takes the air brush's id and answers for every room carved from that brush,
so other_region() returning Vector2i.ZERO means the portal is not one of this room's.
A portal is replaced on every compile too. Its key() - the two air brushes and which of their
openings it is - is what stays. An aperture can be named (set_portal_name(), or the Audio
Portals tool); the name is stored on the wall faces it is cut from, so it survives every edit, and
audio and navigation read the same one: "vault_door" is one door to every system. map.aperture_at(point)
finds the opening a point stands in.
3. Line of sight
func can_see(map_3d: HammeriteMap3D, eye: Vector3, target: Vector3) -> bool:
return map_3d.map.segment_in_air(map_3d.to_local(eye), map_3d.to_local(target))
segment_in_air() walks the line from room to room
through the openings, and is false as soon as it would enter rock - or if either end is in rock.
Never use a raycast against the brushes for this. In a carved world it is wrong both ways: the air
brushes are in the tree, so open space reads as blocked; and a block of rock is one authored brush with
rooms cut out of it, so a line straight through the rock crosses none of its faces and reads as clear.
bvh_raycast() is for picking, not seeing.
segment_in_air() ignores detail brushwork - a table, a crate stack - and entities, by design. A guard
that should lose the player behind a crate asks Godot's physics as well:
var seen: bool = can_see(map_3d, eye, target) \
and get_world_3d().direct_space_state.intersect_ray(
PhysicsRayQueryParameters3D.create(eye, target, 1, [self])).is_empty()
With the native kernel built, segment_in_air() costs microseconds and may be asked from several
threads at once once the world is compiled.
4. When the world changes
Compilation is lazy: an edit that changes the rooms emits map.worldrep_changed, and the next query
compiles. Anything you hold that came from the rooms - a guard's patrol of room keys, a table of
doorways - rebinds there, by key:
func _on_worldrep_changed() -> void:
_patrol_cells = map.get_region_cells(_patrol_room)
_doorways.clear()
A variant switch changes what exists without an edit, and says so on map.variant_changed. A new map
arrives on the HammeriteMap3D's map_changed.
See also
- Hearing for stealth gameplay - sound travels through these openings.
- Navigation for guards - so do paths.
HammeriteMap,HammeriteBoundaryWall, andHammeriteMap3D.is_room_seen_at()for deciding what is worth doing every frame.