Writing an entity class
An entity is a thing placed in a map that is not world geometry: a light, a door, a pickup, a guard's post. It is two halves:
- authored - a
HammeriteEntityin the map: a type name and a dictionary of properties, saved with the map; - running - a node the game builds from it, found by the
classnameproperty. That is your script.
This page builds ExamplePickup from the minimal
example - a box that spins while the game runs and vanishes when triggered - and then the hooks it
does not need: gizmos, handles, brushwork and triggers of your own.
1. Pick a base class
Extend the one matching what the node must be. Two methods are @abstract - set_editor_active()
and apply_entity_property() - so forgetting either is a compile error. Every other hook has a
default that does nothing, and you write only the ones your entity uses.
| Base class | For |
|---|---|
HammeritePointEntityNode3D |
something that only has to be somewhere: a light, a marker, a pickup |
HammeritePointEntityAnimatableBody3D |
something that moves and is collided with: a door leaf, a lift |
HammeritePointEntityRigidBody3D |
something physics moves |
HammeriteBrushEntityNode3D |
brushwork with no physics |
HammeriteBrushEntityStaticBody3D |
brushwork the player walks into |
HammeriteBrushEntityArea3D |
brushwork that notices what is inside it: water, triggers |
class_name ExamplePickup
extends HammeritePointEntityNode3D
The class_name is the classname the map names. It is your game's, so it needs no prefix.
2. Register the type
A type says what its entities carry and the default of each. Register it with core
(HammeriteEntityTypeRegistry), before the map is built, so
the game knows it with or without the editor:
HammeriteEntityTypeRegistry.register_point(&"pickup", {
&"classname": &"ExamplePickup",
&"color": Color.GOLD,
&"spin_degrees": 90.0,
})
This is the only place a default is written. An entity in a map saved before spin_degrees existed
has no value for it, and its node is told 90.0 from here, so the script needs no default of its own.
The editor offers every registered type in the entity tool's list (Cmd E), and
map_editor.register_point_entity_type() registers here too. A game that only wants to add a
property to one of core's types uses extend_point() (extend_point_entity_type() in the editor)
instead: registering replaces.
A property whose default is null holds a value of any type, or none, and the editor shows it with
a type to choose beside the value. For a property whose legal values are a list - a sound, another
entity's name - the editor can offer a dropdown: register_property_choices(type, property,
provider). The editor README lists the rest of its seams.
3. Authored properties
Every property the type declares reaches apply_entity_property() - when the entity is built, when
the map loads, and each time the author changes one in the editor:
func apply_entity_property(prop_name: StringName, value: Variant) -> void:
match prop_name:
&"origin":
position = value
&"color":
_material.albedo_color = value
&"spin_degrees":
_spin_degrees = float(value)
origin is where it stands, in the map's space: set position, not global_position, and the
entity goes wherever the HammeriteMap3D goes. rotation, when the type
has one, is applied for you before this is called.
4. Drawing for the author
set_editor_active(active) is told whether the editor is up over the world - as the node is built
and on every change. It governs what the entity draws for the author, and says nothing about
whether the game is running. A light shows a bulb icon; a spawn point with no mesh shows a marker:
func set_editor_active(active: bool) -> void:
_marker_mesh.visible = active
The pickup's own box is all it needs, so its set_editor_active() does nothing.
Gizmos are lines over the level for what a mesh cannot show - how far something reaches, which
way it looks. draw_editor_gizmos() is asked every frame the editor is up, and writes into a
HammeriteGizmoBuffer in world space. Say which views a shape is
for: the ortho views already outline the entity's box, so a second box there is noise.
func draws_editor_gizmos_only_when_picked() -> bool:
return true
func draw_editor_gizmos(gizmos: HammeriteGizmoBuffer) -> void:
var color: Color = Color.GREEN if entity.selected else Color.WHITE_SMOKE
gizmos.box(get_aabb(), color, HammeriteGizmoBuffer.View.PERSPECTIVE)
gizmos.sphere(global_position, _watch_radius, color)
gizmos.line(global_position, to_global(_look_at), color)
draws_editor_gizmos_only_when_picked() returning true promises the drawing is empty unless the
entity is selected or hovered, so the editor skips asking on every other frame - a level's props are
thousands. Keep the promise by checking entity.selected and entity.hovered yourself, or draw
something always and leave it false.
Handles are points the author drags to set a vector property - where a guard looks, how far a
door travels - instead of typing numbers. Offer them with get_editor_handles(), positioned in world
space; the editor picks, drags and snaps, and hands each new position to move_editor_handle(). What
the position means is yours to say. Here it is a point the entity looks at, stored relative to it:
func get_editor_handles() -> Array[HammeriteEditorHandle]:
return [HammeriteEditorHandle.new(&"look_at", to_global(_look_at), Color.ORANGE)]
func move_editor_handle(handle_id: StringName, to: Vector3) -> void:
if handle_id == &"look_at":
entity.set_property(&"look_at", to_local(to))
Write the value through entity.set_property(), not into the node: the authored property is what is
saved, and the change comes back to apply_entity_property() like any other. The editor records the
whole drag as one undo step by calling move_editor_handle() with the old position, so the method
must work from any position, not only the next one along.
5. Simulation
The world runs from the moment HammeriteMap3D builds it, unless the editor is up over it. The
editor can stop it, run it again, or run just the selection, and put it back. An entity that does
anything while running says so with can_simulate():
func can_simulate() -> bool:
return true
func start_simulation() -> void:
_simulating = true
func stop_simulation() -> void:
_simulating = false
func reset_simulation() -> void:
_simulating = false
_taken = false
_mesh.show()
stop_simulation() is a pause: keep the state reached, so start_simulation() resumes.
reset_simulation() puts the node back as the map built it. Gate _process and _physics_process
on your own flag, as the pickup does with _simulating - the node exists, and is processed, while the
author is editing.
6. Runtime properties and actions
What the inspector shows of the running thing, beside the authored properties (which are
entity.get_property()). The author can watch them and set them while the world runs; none is saved.
func get_runtime_properties() -> Dictionary[StringName, Variant]:
return {&"taken": _taken}
func set_runtime_property(key: StringName, value: Variant) -> void:
if key == &"taken":
_taken = bool(value)
_mesh.visible = !_taken
func reset_runtime_properties() -> void:
set_runtime_property(&"taken", false)
get_runtime_property(key, default) reads one out of get_runtime_properties().
An action is a button in the same panel: something done to the running entity, rather than a
value set on it, and not undoable. Call super() to keep the Trigger every triggerable entity gets:
func get_actions() -> Dictionary[StringName, String]:
var actions: Dictionary[StringName, String] = super()
actions[&"respawn"] = "Respawn"
return actions
func run_action(action_id: StringName) -> void:
if action_id == &"respawn":
reset_runtime_properties()
else:
super(action_id)
7. Triggering
An entity that can be triggered says so, and says what triggering it does:
func can_trigger() -> bool:
return true
func trigger(_impulse: Variant = null) -> void:
set_runtime_property(&"taken", true)
Three things trigger it:
- the author, from Trigger on the editor's right-click menu - with a null impulse;
- a
triggerbrush entity, when a body walks into it: it triggers every entity named in itstargetproperty, handing over itstarget_impulse; - your game, by name, through the map:
var how_many: int = map_3d.trigger_named("vault_door", true)
A name is not an id: every entity with that name that can be triggered is triggered, so one switch can
light a whole corridor. impulse means what the entity says it does - Hammerite's lights read true
and false as on and off and anything else as switch over; a lift might read a floor number.
The editor warns, once per script, about a hook that can never run because the one that allows it is
not written - trigger() without can_trigger(), start_simulation() without can_simulate() - and
about a hook still under a name it used to have, such as get_properties().
8. Brushwork entities
A door, a lift, a pool or a volume that notices who walks in is made of brushes rather than standing
at a point. Register it with register_brush(), and extend one of the HammeriteBrushEntity*
classes. The same hooks apply, with these differences:
- The node is built with a mesh per brush, kept up to date as the author edits them
(
get_brush_meshes()), and - on a body or an area - a convex collision shape per brush (get_brush_shapes()). Their geometry is in map space, so the node stays at the map's origin. - Nothing moves it for you: a brush entity's
origin, where its type has one, moves nothing unlessapply_entity_property()moves something. - A brush entity is lightmapped where it stands. One that moves must hand its faces to the probes first; see Moving brush entities.
A volume that does something to whoever is inside it - a pressure plate, a pool of shadow that makes
the player harder to see - is an Area3D. Listen in _init, and watch only while running, so an
author dragging it through a guard is not the guard walking in:
class_name ShadowVolume
extends HammeriteBrushEntityArea3D
func _init() -> void:
monitoring = false
body_entered.connect(func(body: Node3D) -> void: body.add_to_group(&"in_shadow"))
body_exited.connect(func(body: Node3D) -> void: body.remove_from_group(&"in_shadow"))
func can_simulate() -> bool:
return true
func start_simulation() -> void:
set_deferred(&"monitoring", true)
func stop_simulation() -> void:
set_deferred(&"monitoring", false)
func apply_entity_property(_prop_name: StringName, _value: Variant) -> void:
pass
set_deferred, because a simulation can be started from inside a physics callback, where monitoring
cannot change. HammeriteBrushEntityArea3D already writes set_editor_active(): it draws the brushes
only while the editor is up, so the volume is invisible to whoever plays.
9. In the game
Nothing more: HammeriteMap3D builds the node from classname wherever the map has one of these,
with or without the editor, and starts it. A map saved with a type your game no longer registers
still loads; an entity whose classname no class answers to is reported and left out.