app/console/map.rb

the library's own source, console 0.2.0 · back to Console::Map

# Console::Map -- load a level out of an LDTK export.
#
# LDTK (https://ldtk.io) is the one editor whose data model is close enough to
# the console's to be worth native support: it places entities in a level, and
# an entity here is already a rect with a kind and a bag of properties.
#
#   map = Console::Map.load 'levels/Level_0.ldtk'
#   map.solids          # collision rects, harvested from IntGrid layers
#   map.entities        # [{ kind: :enemy, x:, y:, w:, h:, hp: 3, label: 'boss' }]
#   map.spawn_all       # hand every entity to the entity store
#
# Two details decide whether this works or quietly does not, so they are worth
# stating up front.
#
# COORDINATES. LDTK is a top-left origin with y growing DOWN. The console (and
# DragonRuby underneath it) is bottom-left with y growing UP. Everything is
# flipped on load, so a platform near the top of the LDTK level lands at a
# high y -- which is what bottom-left thinking expects. Pass `flip_y: false`
# to keep raw LDTK coordinates.
#
# FIELD TYPES. An Int/Float/String/Bool field stores its payload under 'value'.
# A Point field does NOT: it stores 'cx'/'cy' as siblings and the 'value' key
# is absent entirely. In mruby an absent key is not a nil value -- chaining
# into one raises `:[] method missing on ~NilClass~` rather than returning nil.
# So every field is read through a __type branch and never assumed to have a
# 'value'. See `self.field_value`.
module Console
  class Map
    # LDTK layer __type values this loader understands. Anything else is
    # ignored rather than guessed at, so an unfamiliar LDTK version degrades
    # to "no solids" instead of to nonsense geometry.
    ENTITY_LAYER = 'Entities'
    INTGRID_LAYER = 'IntGrid'
    TILE_LAYER = 'Tiles'
    AUTO_LAYER = 'AutoLayer'

    # Field __type values that carry their payload under 'value'. Point is
    # deliberately absent: it is the one that does not.
    VALUE_FIELD_TYPES = %w[Int Float String Bool Color Tile Enum Array EntityRef
                           LocalGrid].freeze

    attr_reader :identifier, :path, :width, :height
    attr_reader :entities, :solids, :tiles, :warnings

    # Load and parse an .ldtk file. Returns nil (with a console warning) when
    # the file is missing or unreadable, so a missing level degrades to a
    # visible warning rather than a boot crash.
    #
    # The path is resolved against the booted cart first, so a cart saying
    # `load_map 'maps/Level_0.ldtk'` loads its own level, and the warning below
    # names the file that was actually looked for.
    def self.load(path, options = {})
      path = Assets.resolve path
      unless DR.stat_file(path)
        Console.warn "map not found: #{path}"
        return nil
      end

      contents = DR.read_file path
      if contents.nil? || contents.to_s.length == 0
        Console.warn "map is empty or unreadable: #{path}"
        return nil
      end

      data = DR.parse_json contents
      unless data.is_a?(Hash)
        Console.warn "map is not a JSON object: #{path}"
        return nil
      end

      new data, options.merge(path: path)
    rescue => e
      # Only the IO/parse boundary is rescued. A malformed level is a real
      # error and the cart author needs to see it.
      Console.warn "map failed to load: #{path} (#{e.class}: #{e.message})"
      nil
    end

    # Build a map from an already-parsed Hash. Useful for tests and for levels
    # that arrive over the network rather than off disk.
    def self.from_hash(data, options = {})
      new data, options
    end

    # Read one LDTK field instance into a plain Ruby value.
    #
    # Point is the case that matters: it has no 'value' key at all, so asking
    # for one returns nil and chaining into THAT nil raises. Branching on
    # __type is the only safe way in.
    def self.field_value(field)
      return nil unless field.is_a?(Hash)
      type = field['__type'].to_s

      if type == 'Point'
        return nil unless field.key?('cx') && field.key?('cy')
        return { x: field['cx'].to_f, y: field['cy'].to_f }
      end

      # LDTK renamed the payload key from `value` to `__value`. A loader that
      # knows only the old spelling loses EVERY field on a modern export, and
      # loses them silently: a missing key reads as "no value supplied" rather
      # than as an error, so the level still loads and just comes out bare.
      # Read the current key first and keep the old one as a fallback so both
      # generations parse. Point is branched above -- it has neither key.
      return field['__value'] if field.key?('__value')
      return field['value'] if field.key?('value')
      nil
    end

    def initialize(data, options = {})
      @path = options[:path]
      @flip_y = options.key?(:flip_y) ? options[:flip_y] : true
      @solid_entities = options[:solid_entities] || []
      @one_way_value = options[:one_way_value]
      @warnings = []
      @entities = []
      @solids = []
      @tiles = []

      level = self.class.send :pick_level, data, options[:level]
      if level.nil?
        @identifier = nil
        @width = 0
        @height = 0
        @warnings << 'no level found in map'
        return
      end

      @identifier = level['identifier'].to_s
      @width = (level['pxWid'] || 0).to_f
      @height = (level['pxHei'] || 0).to_f

      read_layers level
    end

    # Entities of one kind, by LDTK identifier ('Enemy') or console kind
    # (:enemy). Both spellings work because LDTK's own naming is PascalCase.
    def entities_of(kind)
      wanted = Console::Str.snake kind.to_s
      @entities.select { |e| e[:kind].to_s == wanted }
    end

    # Push every entity into the store and return what was created.
    #
    # `kind:` is taken from the LDTK __identifier, so an 'Enemy' entity
    # becomes a :enemy entity without the cart naming anything. Extra keys are
    # merged per entity, for carts that want to stamp a cart-specific id.
    def spawn_all(extra = {})
      @entities.map do |attrs|
        Console.entities.spawn nil, attrs.merge(extra)
      end
    end

    def to_s
      "#<Console::Map #{@identifier} #{@entities.size} entities, " \
        "#{@solids.size} solids, #{@tiles.size} tiles>"
    end

    private

    # An .ldtk project nests levels two deep: data['worlds'][n]['levels'][m].
    # Tolerate the other shapes too -- a top-level levels array, or a bare
    # level exported on its own -- because people hand-edit these files.
    def self.pick_level(data, wanted)
      candidates = []

      if data['worlds'].is_a?(Array)
        data['worlds'].each do |world|
          next unless world.is_a?(Hash)
          next unless world['levels'].is_a?(Array)
          world['levels'].each { |l| candidates << l if l.is_a?(Hash) }
        end
      end

      if data['levels'].is_a?(Array)
        data['levels'].each { |l| candidates << l if l.is_a?(Hash) }
      end

      # A single level exported on its own has layers but no wrapper.
      candidates << data if data['layerInstances'].is_a?(Array)

      candidates = candidates.select { |l| l['layerInstances'].is_a?(Array) }
      return nil if candidates.empty?

      if wanted
        match = candidates.find { |l| l['identifier'].to_s == wanted.to_s }
        return match if match
      end

      candidates.first
    end

    def read_layers(level)
      instances = level['layerInstances']
      return unless instances.is_a?(Array)

      instances.each do |layer|
        next unless layer.is_a?(Hash)
        type = layer['__type'].to_s
        case type
        when ENTITY_LAYER then read_entity_layer layer
        when INTGRID_LAYER then read_intgrid_layer layer
        when TILE_LAYER, AUTO_LAYER then read_tile_layer layer
        end
      end
    end

    # --- entities --------------------------------------------------------

    def read_entity_layer(layer)
      instances = layer['entityInstances']
      return unless instances.is_a?(Array)

      instances.each do |inst|
        next unless inst.is_a?(Hash)
        attrs = entity_attributes inst
        if solid_entity? inst, attrs
          @solids << solid_from_entity(attrs)
        else
          @entities << attrs
        end
      end
    end

    # Whether an entity is collision rather than a thing to spawn.
    #
    # The map gets to decide this, so a cart never has to keep a list of every
    # entity type in the level that happens to be solid -- a list that silently
    # rots the moment someone adds a platform in LDTK. Three ways to say it, in
    # order of explicitness:
    #
    #   1. `solid_entities:` names the LDTK identifier outright.
    #   2. The entity carries a truthy `Solid` bool field.
    #   3. The entity carries a truthy `OneWay` bool field. A pass-through
    #      platform is still a platform, so one-way implies solid instead of
    #      being treated as an unrelated flag.
    #
    # (2) and (3) are read off the entity instance, and LDTK bakes an entity
    # type's field defaults into every placed copy -- so ticking `Solid` once
    # on the Platform TYPE in the editor marks every Platform in the level,
    # and there is nothing left to remember on the console side.
    def solid_entity?(inst, attrs)
      return true if @solid_entities.include?(inst['__identifier'].to_s)
      return true if attrs[:solid]
      return true if attrs[:one_way]
      false
    end

    # LDTK entity -> console entity hash.
    #
    # Fields are applied BEFORE the geometry so that a stray field can never
    # overwrite x/y/w/h and produce an entity with no usable rect.
    def entity_attributes(inst)
      attrs = {}
      fields = inst['fieldInstances']

      if fields.is_a?(Array)
        fields.each do |field|
          next unless field.is_a?(Hash)
          name = field['__identifier']
          next if name.nil? || name.to_s.empty?
          value = self.class.field_value field
          # A nil value means the field was left at an LDTK default that has
          # no payload (Point with no cx/cy). Keeping the key would shadow the
          # cart's own spawn default with nil, so it is dropped.
          attrs[Console::Str.snake(name).to_sym] = value unless value.nil?
        end
      end

      w = (inst['width'] || 16).to_f
      h = (inst['height'] || 16).to_f

      attrs[:kind] = Console::Str.snake(inst['__identifier']).to_sym
      attrs[:ldtk_identifier] = inst['__identifier'].to_s
      attrs[:w] = w
      attrs[:h] = h
      attrs[:x] = (inst['px'] || [0, 0])[0].to_f
      attrs[:y] = console_y((inst['px'] || [0, 0])[1].to_f, h)
      attrs
    end

    def solid_from_entity(attrs)
      {
        x: attrs[:x],
        y: attrs[:y],
        w: attrs[:w],
        h: attrs[:h],
        # `one_way` on the entity is the LDTK-native spelling of a pass-through
        # platform; honour both that and the generic flag.
        one_way: attrs[:one_way] ? true : false,
        # Deliberately the same shape as an IntGrid-derived solid, so a cart
        # reads every entry in `solids` the same way without caring which layer
        # it came from.
        kind: :solid
      }
    end

    # --- intgrid (collision) ---------------------------------------------

    # LDTK's own convention for collision: an IntGrid layer where any non-zero
    # cell is solid. The layer's grid size is the tile size, and rows run
    # top-down, so each row is mirrored into console space.
    def read_intgrid_layer(layer)
      csv = layer['intGridCsv']
      return unless csv.is_a?(Array)

      grid = (layer['__gridSize'] || 16).to_f
      return if grid <= 0

      cols = (layer['__cWid'] || (@width / grid).ceil).to_i
      rows = (layer['__cHei'] || (@height / grid).ceil).to_i

      csv.each_with_index do |value, i|
        next if value.to_i == 0

        col = i % cols
        # NOTE: mruby's `/` is ALWAYS float division -- 181 / 20 is 9.05, not
        # 9. Without this .to_i every solid in the row lands at a slightly
        # different y and a level's floor comes out subtly stepped. This is the
        # single most mruby-specific bug in this file.
        row = (i / cols).to_i
        # Guard against a short or ragged csv rather than trusting the
        # dimensions; a malformed grid should not invent geometry.
        next if row >= rows

        @solids << {
          x: col * grid,
          y: @height - ((row + 1) * grid),
          w: grid,
          h: grid,
          one_way: one_way_cell?(value),
          kind: :solid
        }
      end
    end

    # `one_way_value:` lets a level mark pass-through platforms with a
    # distinct IntGrid value (2, say) instead of requiring a second layer.
    def one_way_cell?(value)
      return false if @one_way_value.nil?
      value.to_i == @one_way_value.to_i
    end

    # --- tiles ------------------------------------------------------------

    # Tiles are collected but not drawn: this is data, not a renderer.
    # Each entry keeps both LDTK's source position and a console-space px so a
    # future draw pass never has to re-derive the flip.
    def read_tile_layer(layer)
      grid = (layer['__gridSize'] || 16).to_f
      return if grid <= 0

      ['gridTiles', 'autoLayerTiles'].each do |key|
        list = layer[key]
        next unless list.is_a?(Array)

        list.each do |t|
          next unless t.is_a?(Hash)
          px = t['px'] || [0, 0]
          @tiles << {
            px: [px[0].to_f, console_y(px[1].to_f, grid)],
            src: t['src'] || [0, 0],
            flip_x: (t['f'] || 0).to_i == 1,
            flip_y: (t['f'] || 0).to_i == 2,
            tile_id: t['t'],
            size: grid,
            layer: layer['__identifier'].to_s
          }
        end
      end
    end

    # LDTK y (down, from the top of the level) -> console y (up, from the
    # bottom), converting a top-down height to a bottom-up one.
    def console_y(ldtk_y, height = 0)
      return ldtk_y unless @flip_y
      @height - ldtk_y - height
    end
  end
end