Source code for dustmaker.entity

"""
Module defining basic entity representations as well as a custom entity
object for each entity in Dustforce.
"""
import copy
from enum import IntEnum
import math
from typing import cast, Dict, Optional, Tuple, Type, TypeVar

from .transform import TxMatrix
from .variable import (
    Variable,
    VariableArray,
    VariableBool,
    VariableFloat,
    VariableInt,
    VariableString,
    VariableStruct,
    VariableUInt,
    VariableVec2,
)

T = TypeVar("T")


def _get_python_prop_type(prop_type: Type[Variable]) -> str:
    """Returns the python type name for variable types for use
    with documentation."""
    if prop_type is VariableBool:
        return "bool"
    if prop_type in (VariableInt, VariableUInt):
        return "int"
    if prop_type is VariableFloat:
        return "float"
    if prop_type is VariableString:
        return "bytes"
    if prop_type is VariableVec2:
        return "(float, float)"
    if prop_type is VariableStruct:
        return "dict[str, Variable]"
    if prop_type is VariableArray:
        return "MutableSequence"
    raise TypeError("unexpected variable type")


def bind_prop(
    prop_name: str,
    prop_type: Type[Variable],
    default: T,
    doc: Optional[str] = None,
    doc_add_type=True,
    objtype=False,
) -> property:
    """Define getters and setters for a named property backed by the map's
    var mapping.

    :meta private:
    """

    # Removed "-> T" type annotation on this because it was making docs ugly.
    def _get(self):
        """get the property with given default"""
        var = self.variables.get(prop_name)
        if var is None:
            result = default() if callable(default) else default
            if objtype:
                self.variables[prop_name] = result
            return result
        if not isinstance(var, prop_type):
            raise ValueError("unexpected property type")
        if objtype:
            return cast(T, var)
        return cast(T, var.value)

    def _set(self, val: T) -> None:
        """set the property"""
        self.variables[prop_name] = val if objtype else prop_type(val)

    def _del(self) -> None:
        """delete the property. Ignored if the property is not set."""
        self.variables.pop(prop_name, None)

    if doc is None:
        doc = f"Wrapper around `variables['{prop_name}']` of type :class:`{prop_type.__name__}`."
    if doc_add_type:
        doc = f"{_get_python_prop_type(prop_type)}: {doc}"
    if not objtype:
        doc = f"{doc} Defaults to `{default}`."

    return property(fget=_get, fset=_set, fdel=_del, doc=doc)


def bind_prop_arr(
    prop_name: str,
    elem_type: Type[Variable],
    doc: Optional[str] = None,
    doc_add_type=True,
) -> property:
    """Convenience wrapper around bind_prop for array properties

    :meta private:
    """
    if doc is None:
        doc = f"Wrapper around `variables['{prop_name}']` of type `VariableArray[{elem_type.__name__}]`."
    if doc_add_type:
        doc = f"MutableSequence[{_get_python_prop_type(elem_type)}]: {doc}"

    return bind_prop(
        prop_name,
        VariableArray,
        lambda: VariableArray(elem_type),
        doc=doc,
        doc_add_type=False,
        objtype=True,
    )


[docs]class Entity: """Base class representing an entity object in a map. Commonly used entities have subclasses of :class:`Entity` to represent them. Those that do not will simply be of type :class:`Entity` and have their :attr:`etype` field set to control how the Dustforce engine will interpret the entity. To add new Entity types simply extend this class and include a `TYPE_IDENTIFIER` class attribute that matches the type identifier used internally by Dustforce. The level reader and writer will then automatically use these types (as long as the classes have been initialized). Additionally you can override the :meth:`remap_ids` and :meth:`transform` methods to handle any special processing this entity type requires. If you make these changes consider contributing your entity specialization as a pull request. Attributes: etype (str): The entity type name. This typically matches the `TYPE_IDENTIFIER` attribute of the concrete type of this object. variables (dict[str, Variable]): Persist data variable mapping for this entity rotation (16-bit uint): Clockwise rotation of the entity ranging from 0 to 0xFFFF. 0x4000 corresponds to a 90 degree rotation, 0x8000 to 180 degrees, 0xC000 to 270 degrees. This rotation is logically applied after any flips have been applied. layer (8-bit uint): Layer to render the entity in flip_x (bool): Flip the entity horizontally flip_y (bool): Flip the entity vertically visible (bool): Is the entity visible """ _known_types: Dict[str, Type["Entity"]] = {} @classmethod def __init_subclass__(cls) -> None: """ Register subclasses as a known type. """ etype = getattr(cls, "TYPE_IDENTIFIER", None) if etype is not None: cls._known_types[etype] = cls @classmethod def _from_raw( cls, etype: str, variables: Dict[str, Variable], rotation: int, layer: int, flip_x: bool, flip_y: bool, visible: bool, ) -> "Entity": """Construct an entity from its map format definition.""" subcls = cls._known_types.get(etype) if subcls is not None: return subcls(variables, rotation, layer, flip_x, flip_y, visible) entity = Entity(variables, rotation, layer, flip_x, flip_y, visible) entity.etype = etype return entity def __init__( self, variables: Optional[Dict[str, Variable]] = None, rotation=0, layer=18, flip_x=False, flip_y=False, visible=True, ) -> None: self.etype = getattr(self, "TYPE_IDENTIFIER", "unknown_type") self.variables = copy.deepcopy(variables) if variables is not None else {} self.rotation = rotation self.layer = layer self.flip_x = flip_x self.flip_y = flip_y self.visible = visible def __repr__(self) -> str: return "Entity: (%s, %d, %d, %d, %d, %d, %s)" % ( self.etype, self.rotation, self.layer, self.flip_x, self.flip_y, self.visible, repr(self.variables), )
[docs] def remap_ids(self, id_map: Dict[int, int]) -> None: """Overridable method to allow an entity to remap any internally stored IDs."""
[docs] def transform(self, mat: TxMatrix) -> None: """Generic transform implementation for entities. Transforms the Entity :attr:`rotation` and :attr:`flip_y` attributes (:attr:`flip_x` is redundant). Many subtypes will perform additional transformations on their :attr:`variables`. """ self.rotation = self.rotation - int(0x10000 * mat.angle / math.pi / 2) & 0xFFFF if mat.flipped: self.flip_y = not self.flip_y self.rotation = -self.rotation & 0xFFFF
[docs]class Emitter(Entity): """Emitter entity class""" TYPE_IDENTIFIER = "entity_emitter" e_rotation = bind_prop("e_rotation", VariableInt, 0) draw_depth_sub = bind_prop("draw_depth_sub", VariableUInt, 0) r_rotation = bind_prop("r_rotation", VariableBool, False) r_area = bind_prop("r_area", VariableBool, False) width = bind_prop("width", VariableInt, 480) height = bind_prop("height", VariableInt, 480) emitter_id = bind_prop("emitter_id", VariableUInt, 0)
[docs]class CheckPoint(Entity): """Checkpoint entity class""" TYPE_IDENTIFIER = "check_point"
[docs] def transform(self, mat: TxMatrix) -> None: """Transform the trigger area""" super().transform(mat) areas = self.trigger_areas for i, pos in enumerate(areas): # trigger_area locations are relative the entity itself so should # not be sampled with offset. areas[i] = mat.sample(*pos, with_offset=False)
trigger_areas = bind_prop_arr("trigger_area", VariableVec2)
[docs]class EndZone(CheckPoint): """Proximity based end zone (purple flag) entity class""" TYPE_IDENTIFIER = "level_end_prox" finished = bind_prop("finished", VariableBool, False)
[docs]class Trigger(Entity): """Trigger entity entity class""" TYPE_IDENTIFIER = "base_trigger" width = bind_prop("width", VariableInt, 500)
[docs]class FogTrigger(Trigger): """Fog trigger entity class""" TYPE_IDENTIFIER = "fog_trigger" def _fog_by_index( self, index: int, val: Optional[Tuple[int, float]] ) -> Tuple[int, float]: """Internal method to get the fog values at a given index corresponding to either a layer or sublayer""" if index < 0 or index >= 26 * 21: raise IndexError("invalid fog index") colours = self.variables.setdefault("fog_trigger", VariableArray(VariableUInt)) if not isinstance(colours, VariableArray): raise ValueError("fog_trigger variable not an array") pers = self.variables.setdefault("fog_per", VariableArray(VariableFloat)) if not isinstance(pers, VariableArray): raise ValueError("fog_per variable not an array") while index >= len(colours): colours.append(0x111118) while index >= len(pers): pers.append(0.0) result = (colours[index], pers[index]) if val is not None: colours[index], pers[index] = val return result
[docs] def normalize(self) -> None: """Resizes :attr:`gradient`, :attr:`colours`, and :attr:`pers` arrays to be the correct length for the given :attr:`has_sub_layers` setting. """ want_length = 21 * 26 if self.has_sub_layers else 21 while len(self.colours) > want_length: self.colours.pop() while len(self.pers) > want_length: self.pers.pop() while len(self.colours) < want_length: self.colours.append(0x111118) while len(self.pers) < want_length: self.pers.append(0.0) while len(self.gradient) > 3: self.gradient.pop() while len(self.gradient) < 3: self.gradient.append(0)
[docs] @staticmethod def get_layer_index(layer: int, sublayer: Optional[int] = None) -> int: """Helper function to find fog data for a given layer/sublayer. Arguments: layer (int): Must be between 0 and 20 inclusive. sublayer (int): Must be between 0 and 24 inclusive. Returns: Index into :attr:`colours` and :attr:`pers` where fog data is stored for the given `layer` and (if present) `sublayer`. """ assert 0 <= layer <= 20 if sublayer is None: return layer assert 0 <= sublayer <= 20 return (sublayer + 1) * 21 + layer
speed = bind_prop("fog_speed", VariableFloat, 5.0) gradient = bind_prop_arr("gradient", VariableUInt) gradient_middle = bind_prop("gradient_middle", VariableFloat, 0.0) star_bottom = bind_prop("star_bottom", VariableFloat, 0.0) star_middle = bind_prop("star_middle", VariableFloat, 0.4) star_top = bind_prop("star_top", VariableFloat, 1.0) has_sub_layers = bind_prop( "has_sub_layers", VariableBool, False, "Controls if sublayer fog data is enabled for this trigger", ) colours = bind_prop_arr( "fog_colour", VariableUInt, "Fog colour in 0xRRGGBB format for each (sub)layer.", ) pers = bind_prop_arr( "fog_per", VariableFloat, "Mixing coefficient for the fog each (sub)layer from 0.0 to 1.0.", )
[docs]class AmbienceTrigger(Trigger): """Ambience trigger entity class""" TYPE_IDENTIFIER = "ambience_trigger" speed = bind_prop("ambience_speed", VariableFloat, 5) sound_names = bind_prop_arr("sound_ambience_names", VariableString) sound_vols = bind_prop_arr("sound_ambience_vol", VariableFloat)
[docs]class MusicTrigger(Trigger): """Music trigger entity class""" TYPE_IDENTIFIER = "music_trigger" speed = bind_prop("music_speed", VariableFloat, 5) sound_names = bind_prop_arr("sound_music_names", VariableString) sound_vols = bind_prop_arr("sound_music_vol", VariableFloat)
[docs]class SpecialTrigger(Trigger): """Max special trigger entity class""" TYPE_IDENTIFIER = "special_trigger"
[docs]class TextTrigger(Entity): """Text trigger entity class""" TYPE_IDENTIFIER = "text_trigger" hide = bind_prop("hide", VariableBool, False) text = bind_prop("text_string", VariableString, b"")
[docs]class DeathZone(Entity): """Death zone entity class""" TYPE_IDENTIFIER = "kill_box"
[docs] def transform(self, mat: TxMatrix) -> None: """Transform the death zone width and height""" super().transform(mat) tw, th = mat.sample(self.width, self.height, with_offset=False) self.width = int(abs(tw)) self.height = int(abs(th))
width = bind_prop("width", VariableInt, 0) height = bind_prop("height", VariableInt, 0)
[docs]class AIController(Entity): """AI controller node entity class""" TYPE_IDENTIFIER = "AI_controller"
[docs] def remap_ids(self, id_map: Dict[int, int]) -> None: """Remap the puppet id.""" super().remap_ids(id_map) self.puppet = id_map.get(self.puppet, 0)
[docs] def transform(self, mat: TxMatrix) -> None: """Transform the controller waypoints.""" super().transform(mat) nodes = self.nodes for i, pos in enumerate(nodes): nodes[i] = mat.sample(*pos)
nodes = bind_prop_arr("nodes", VariableVec2) node_wait_times = bind_prop_arr("nodes_wait_time", VariableInt) puppet = bind_prop("puppet_id", VariableUInt, 0)
[docs]class CameraNodeType(IntEnum): """ Enum defining the different camera node types """ NORMAL = 1 DETACH = 2 CONNECT = 3 INTEREST = 4 FORCE_CONNECT = 5
[docs]class CameraNode(Entity): """Camera node entity class""" TYPE_IDENTIFIER = "camera_node"
[docs] def remap_ids(self, id_map: Dict[int, int]) -> None: """Remap the connected camera node IDs.""" super().remap_ids(id_map) nds = self.nodes for i, nd_id in enumerate(nds): nds[i] = id_map.get(nd_id, 0)
[docs] def transform(self, mat: TxMatrix) -> None: """Transform the camera zoom and width""" super().transform(mat) scale = mat.scale self.zoom = int(round(self.zoom * scale)) self.width = int(round(self.width * scale))
node_type = bind_prop( "node_type", VariableInt, CameraNodeType.NORMAL, "Camera node type, see :class:`CameraNodeType` enum.", ) test_widths = bind_prop_arr("test_widths", VariableInt) nodes = bind_prop_arr("c_node_ids", VariableUInt) control_widths = bind_prop_arr("control_widths", VariableVec2) zoom = bind_prop("zoom_h", VariableInt, 1080) width = bind_prop("width", VariableInt, 520)
[docs]class LevelEnd(Entity): """Level end flag class""" TYPE_IDENTIFIER = "level_end"
[docs] def remap_ids(self, id_map: Dict[int, int]) -> None: """Remap entity IDs attached to this flag.""" super().remap_ids(id_map) ents = self.entities for i, ent_id in enumerate(ents): ents[i] = id_map.get(ent_id, 0)
entities = bind_prop_arr("ent_list", VariableUInt) finished = bind_prop("finished", VariableBool, False)
[docs]class ScoreBook(Entity): """Score book class""" TYPE_IDENTIFIER = "score_book" book_type = bind_prop("book_type", VariableString, b"")
[docs]class LevelDoor(Trigger): """Level door class""" TYPE_IDENTIFIER = "level_door" file_name = bind_prop("file_name", VariableString, b"") door_set = bind_prop("door_set", VariableInt, 0)
[docs]class RedKeyDoor(Entity): """Red key door class""" TYPE_IDENTIFIER = "giga_gate" keys_needed = bind_prop("key_needed", VariableInt, 1)
[docs]class EntityHittable(Entity): """Base class for all 'hittable' types""" scale = bind_prop("dm_scale", VariableFloat, 1.0)
[docs] def transform(self, mat: TxMatrix) -> None: """Adjust the entity scale""" super().transform(mat) self.scale = self.scale * mat.scale
[docs]class Enemy(EntityHittable): """Base class for all enemy types Subclasses can override :attr:`FILTH` to control how much "filth" is attributed to this entity. The DF file format requires a totalling of all filth in a level for completion calculations. """ FILTH: int = 1
[docs]class EnemyLightPrism(Enemy): """Light prism entity class""" TYPE_IDENTIFIER = "enemy_tutorial_square"
[docs]class EnemyHeavyPrism(Enemy): """Heavy prism entity class. Note that although heavy prisms reward 3 dust when cleansing them they only count as 1 filth from the perspective of completion calculations.""" TYPE_IDENTIFIER = "enemy_tutorial_hexagon"
[docs]class EnemySlimeBeast(Enemy): """Slime beast entity class""" TYPE_IDENTIFIER = "enemy_slime_beast" FILTH = 9
[docs]class EnemySlimeBarrel(Enemy): """Slime barrel (paint can) entity class""" TYPE_IDENTIFIER = "enemy_slime_barrel" FILTH = 3
[docs]class EnemySpringBall(Enemy): """Spring ball/blob entity class""" TYPE_IDENTIFIER = "enemy_spring_ball" FILTH = 5
[docs]class EnemySlimeBall(Enemy): """Slime ball (lab turkey) entity class""" TYPE_IDENTIFIER = "enemy_slime_ball" FILTH = 3
[docs]class EnemyTrashTire(Enemy): """Trash tire entity class""" TYPE_IDENTIFIER = "enemy_trash_tire" FILTH = 3 max_fall_speed = bind_prop("max_fall_speed", VariableFloat, 800.0)
[docs]class EnemyTrashBeast(Enemy): """Trash beast (golem) entity class""" TYPE_IDENTIFIER = "enemy_trash_beast" FILTH = 9
[docs]class EnemyTrashCan(Enemy): """Trash can entity class""" TYPE_IDENTIFIER = "enemy_trash_can" FILTH = 9
[docs]class EnemyTrashBall(Enemy): """Trash ball entity class""" TYPE_IDENTIFIER = "enemy_trash_ball" FILTH = 3
[docs]class EnemyBear(Enemy): """Bear entity class""" TYPE_IDENTIFIER = "enemy_bear" FILTH = 9
[docs]class EnemyTotemLarge(Enemy): """Large totem (stoneboss) entity class""" TYPE_IDENTIFIER = "enemy_stoneboss" FILTH = 12
[docs]class EnemyTotemSmall(Enemy): """Totem entity class""" TYPE_IDENTIFIER = "enemy_stonebro" FILTH = 3
[docs]class EnemyPorcupine(Enemy): """Porcupine entity class""" TYPE_IDENTIFIER = "enemy_porcupine"
[docs]class EnemyWolf(Enemy): """Wolf entity class""" TYPE_IDENTIFIER = "enemy_wolf" FILTH = 5
[docs]class EnemyTurkey(Enemy): """Turkey (critter) entity class""" TYPE_IDENTIFIER = "enemy_critter" FILTH = 3
[docs]class EnemyFlag(Enemy): """Flag entity class""" TYPE_IDENTIFIER = "enemy_flag" FILTH = 5
[docs]class EnemyScroll(Enemy): """Scroll entity class""" TYPE_IDENTIFIER = "enemy_scrolls"
[docs]class EnemyTreasure(Enemy): """Treasure entity class""" TYPE_IDENTIFIER = "enemy_treasure"
[docs]class EnemyChestTreasure(Enemy): """Chest that spawns treasures entity class""" TYPE_IDENTIFIER = "enemy_chest_treasure" FILTH = 9
[docs]class EnemyChestScrolls(Enemy): """Chest that spawns scross entity class""" TYPE_IDENTIFIER = "enemy_chest_scrolls" FILTH = 9
[docs]class EnemyButler(Enemy): """Butler entity class""" TYPE_IDENTIFIER = "enemy_butler"
[docs]class EnemyMaid(Enemy): """Maid entity class""" TYPE_IDENTIFIER = "enemy_maid"
[docs]class EnemyKnight(Enemy): """Knign entity class""" TYPE_IDENTIFIER = "enemy_knight" FILTH = 9
[docs]class EnemyGargoyleBig(Enemy): """Big (punching) gargoyle entity class""" TYPE_IDENTIFIER = "enemy_gargoyle_big" FILTH = 5
[docs]class EnemyGargoyleSmall(Enemy): """Gargoyle (mansion turkey) entity class""" TYPE_IDENTIFIER = "enemy_gargoyle_small" FILTH = 3
[docs]class EnemyBook(Enemy): """Book entity class""" TYPE_IDENTIFIER = "enemy_book" FILTH = 3
[docs]class EnemyHawk(Enemy): """Hawk entity class""" TYPE_IDENTIFIER = "enemy_hawk" FILTH = 3
[docs]class EnemyKey(Enemy): """Key entity class""" TYPE_IDENTIFIER = "enemy_key" FILTH = 1
[docs] def remap_ids(self, id_map: Dict[int, int]) -> None: """Remap the door ID.""" super().remap_ids(id_map) self.door = id_map.get(self.door, 0)
[docs] def transform(self, mat: TxMatrix) -> None: """Transform the last know coordinates.""" super().transform(mat) self.lastX, self.lastY = mat.sample(self.lastX, self.lastY)
door = bind_prop("door", VariableUInt, 0, "ID of door entity") lastX = bind_prop("lastKnowX", VariableFloat, 0.0) lastY = bind_prop("lastKnowY", VariableFloat, 0.0)
[docs]class EnemyDoor(Enemy): """Door entity class""" TYPE_IDENTIFIER = "enemy_door" FILTH = 0
[docs]class Apple(EntityHittable): """Apple entity class""" TYPE_IDENTIFIER = "hittable_apple"
[docs]class DustCharacter(EntityHittable): """Normal playable dust character entity types"""
[docs]class Dustman(DustCharacter): """Dustman entity class""" TYPE_IDENTIFIER = "dust_man"
[docs]class Dustgirl(DustCharacter): """Dustgirl entity classs""" TYPE_IDENTIFIER = "dust_girl"
[docs]class Dustkid(DustCharacter): """Dustkid entity class""" TYPE_IDENTIFIER = "dust_kid"
[docs]class Dustworth(DustCharacter): """Dustworth entity class""" TYPE_IDENTIFIER = "dust_worth"
[docs]class Dustwraith(DustCharacter): """Dustwraith entity class""" TYPE_IDENTIFIER = "dust_wraith"
[docs]class Leafsprite(DustCharacter): """Leaf sprite entity class""" TYPE_IDENTIFIER = "leaf_sprite"
[docs]class Trashking(DustCharacter): """Trash king entity class""" TYPE_IDENTIFIER = "trash_king"
[docs]class Slimeboss(DustCharacter): """Slime boss entity class""" TYPE_IDENTIFIER = "slime_boss"
[docs]class CustomScoreBook(Entity): """Custom score book (tome) entity class""" TYPE_IDENTIFIER = "custom_score_book" level_list = bind_prop("level_list_id", VariableInt, 0, "ID of StringList entity")
[docs]class StringList(Trigger): """Data container entity""" TYPE_IDENTIFIER = "z_string_list" data = bind_prop_arr("list", VariableString)
_NOTES = """ Missing persist types and vars custom_score_book level_list_id dustmod_entity_tool dm_scale target_id dustmod_trigger is_square filth_switch height width z_button entity_ids start_x start_y z_char_force character z_dash_trigger disable z_kill_trigger kill only_dustman only_enemies stun stun_direction stun_force stun_radial z_level_trigger filename z_particle_trigger emitters particles z_physics_trigger air_global attack_force_heavy attack_force_light d_dash_max d_skill_combo_max dash_speed di_global di_move_max di_speed di_speed_wall_lock fall_accel fall_max fric_global global heavy_fall_threshold hitrise_speed hop_a hover_accel hover_fall_threshold idle_fric jump_a land_fric roof_fric roof_run_length run_accel run_accel_over run_global run_max run_start skid_fric skid_threshold slope_max slope_slid_speed wall_run_length wall_slide_speed z_respawner entity_ids max_respawns respawn_time z_scale_trigger scale z_string_list list z_teleport_trigger tele_x tele_y z_text_prop_trigger colour font font_size layer sublayer text text_rotation text_scale z_wind_generator direction force z_wind_trigger pressure_accelleration pressure_advection pressure_diffusion velocity_advection velocity_diffusion velocity_friction_a velocity_friction_b velocity_friction_c vorticity """