Not initialized error

the code is:

# units.cr
require "./gamedata"
require "./supports/macros"
require "./modules"
require "sdl"
require "./lib/sdl/src/image"
require "./lib/sdl/src/mix"
require "./lib/sdl/src/ttf"
require "./buildings"
require "./pathfinder"
require "set"

class TDEnemy
  # === Классовые параметры (аналог class-атрибутов питона) ===
  class_field(moving_type, String, "soil")
  class_field(display_name, String, "unit_simple_walker")
  class_field(max_health, Int32, 100)
  class_field(width, Float64, 0.7)     # размер в КЛЕТКАХ, float — как в питоне
  class_field(height, Float64, 0.7)
  class_field(speed, Float64, 1.5)     # клеток в секунду
  class_field(attack_range, Float64, 8.0)    # пригодится для пулей
  class_field(attack_cooldown, Float64, 0.5)
  class_field(damage, Int32, 15)

  class_property registry : Hash(String, TDEnemy.class) = {} of String => TDEnemy.class
  macro inherited
    # срабатывает при определении КАЖДОГО подкласса:
    # класс сам вписывает себя в реестр, ничего руками добавлять не надо
    TDEnemy.register({{ @type.stringify }}, {{ @type }})
  end

  # === Состояние экземпляра ===
  property gamedata : Gamedata
  property world_x : Float64
  property world_y : Float64
  property health : Int32
  property alive : Bool = true
  property image : SDL::Texture?
  property facing_angle : Float64 = 0.0   # куда смотрим (градусы) — пригодится для copy_ex
  property move_angle : Float64 = 0.0
  property path : Array({Float64, Float64}) = [] of {Float64, Float64}
  property current_waypoint : {Float64, Float64}? = nil
  property target_turret : Building? = nil
  property current_cell : {Int32, Int32}? = nil
  property damage_visual_timer : Float64 = 0.0

  property path_recalc_interval : Float64 = 2.0
  property path_recalc_timer : Float64

  def initialize(@gamedata, x : Int32, y : Int32)
    @world_x = x.to_f64 + 0.5
    @world_y = y.to_f64 + 0.5

    # классовые значения читаем ТОЛЬКО через self.class —
    # @x = @@x в базовом классе всегда видит базовый дефолт (см. max_health)
    @health = self.class.max_health
    @image = gamedata.textures[self.class.display_name]?.as?(SDL::Texture)

    # размазываем пересчёты пути по времени, чтобы толпа не считала A* в один кадр
    @path_recalc_timer = rand(@path_recalc_interval.not_nil!)

    gamedata.units << self

    cell_x = @world_x.to_i
    cell_y = @world_y.to_i
    if 0 <= cell_x < gamedata.ширина_сетки && 0 <= cell_y < gamedata.высота_сетки
      gamedata.grid[cell_x][cell_y].units.add(self)
      @current_cell = {cell_x, cell_y}
    end
  end
  def self.register(name : String, klass : TDEnemy.class) : Nil
      registry[name] = klass
    end
  def update(dt : Float64) : Nil
    return unless alive

    if damage_visual_timer > 0
      @damage_visual_timer -= 300 * dt
      @damage_visual_timer = 0.0 if @damage_visual_timer < 0
    end

    @path_recalc_timer -= dt
    if @path_recalc_timer <= 0
      @path_recalc_timer = @path_recalc_interval
      request_new_path
    end

    update_facing
    update_movement(dt)
  end

  def request_new_path : Nil
    target = find_closest_turret
    @target_turret = target
    return unless target

    start = {world_x.to_i, world_y.to_i}
    # клетка турели непроходима — find_path_multi сам найдет соседнюю
    target_pos = {target.x + target.width // 2, target.y + target.height // 2}

    path = gamedata.pathfinder.find_path_multi(
      start, target_pos, moving_type, {world_x, world_y}
    )
    unless path.empty?
      @path = path
      advance_waypoint
    end
  end

  def find_closest_turret : Building?
    closest : Building? = nil
    min_dist = Float64::INFINITY

    gamedata.buildings.each do |b|
      next unless b.alive
      next unless b.is_a?(Turret)

      dx = world_x - b.x - 0.5
      dy = world_y - b.y - 0.5
      dist = Math.sqrt(dx * dx + dy * dy)
      if dist < min_dist
        min_dist = dist
        closest = b
      end
    end
    closest
  end

  def update_facing : Nil
    t = target_turret
    return unless t && t.alive

    dx = (t.x + t.width / 2.0) - world_x
    dy = (t.y + t.height / 2.0) - world_y
    @facing_angle = Math.atan2(dy, dx) * 180.0 / Math::PI
  end

  def update_movement(dt : Float64) : Nil
    t = target_turret
    request_new_path if t.nil? || !t.alive || current_waypoint.nil?

    wp = current_waypoint
    return unless wp

    # Уперлись в стену — стоим (пока не стреляем). Бомбер потом
    # переопределит on_blocked_by_wall и будет взрываться
    next_x = wp[0].to_i
    next_y = wp[1].to_i
    if 0 <= next_x < gamedata.ширина_сетки && 0 <= next_y < gamedata.высота_сетки
      b = gamedata.grid[next_x][next_y].building
      if b && b.is_wall
        on_blocked_by_wall
        return
      end
    end

    dx = wp[0] - world_x
    dy = wp[1] - world_y
    dist = Math.sqrt(dx * dx + dy * dy)

    @move_angle = Math.atan2(dy, dx) * 180.0 / Math::PI if dist > 0.01

    step = speed * dt

    if dist <= step * 2.0
      # допрыгали — телепорт в waypoint, чтобы не дрожать на микро-дистанциях
      @world_x = wp[0]
      @world_y = wp[1]
      advance_waypoint
    else
      @world_x += (dx / dist) * step
      @world_y += (dy / dist) * step
    end

    update_cell
  end

  def on_blocked_by_wall : Nil
    # пока просто стоим
  end

  def advance_waypoint : Nil
    @current_waypoint = path.empty? ? nil : path.shift
  end

  def update_cell : Nil
    new_cx = world_x.to_i
    new_cy = world_y.to_i

    return if current_cell == {new_cx, new_cy}

    grid = gamedata.grid
    w = gamedata.ширина_сетки
    h = gamedata.высота_сетки

    if old = current_cell
      grid[old[0]][old[1]].units.delete(self) if 0 <= old[0] < w && 0 <= old[1] < h
    end

    if 0 <= new_cx < w && 0 <= new_cy < h
      grid[new_cx][new_cy].units.add(self)
      @current_cell = {new_cx, new_cy}
    else
      @current_cell = nil
    end
  end

  def take_damage(amount : Int32) : Nil
    return unless alive
    @health -= amount
    @damage_visual_timer = 255.0
    die if health <= 0
  end

  def die : Nil
    return unless alive
    @alive = false
    if old = current_cell
      w = gamedata.ширина_сетки
      h = gamedata.высота_сетки
      if 0 <= old[0] < w && 0 <= old[1] < h
        gamedata.grid[old[0]][old[1]].units.delete(self)
      end
    end
    # TODO (когда будут пули): лут, взрыв, звук, удаление из gamedata.units
    # (удалять из массива во время итерации нельзя — сделаем reject! в конце кадра)
  end

  def draw : Nil
    return unless alive

    camera = gamedata.camera
    csx = gamedata.cellsizex
    csy = gamedata.cellsizey

    screen_x = (world_x * csx * camera.zoom + camera.dx).to_i
    screen_y = (world_y * csy * camera.zoom + camera.dy).to_i
    draw_w = (width * csx * camera.zoom).to_i
    draw_h = (height * csy * camera.zoom).to_i

    return if screen_x + draw_w < 0 || screen_x > gamedata.screen_x
    return if screen_y + draw_h < 0 || screen_y > gamedata.screen_y

    tex = image
    return unless tex

    dst = SDL::Rect.new(screen_x - draw_w // 2, screen_y - draw_h // 2, draw_w, draw_h)

    if damage_visual_timer > 0
      ratio = (damage_visual_timer / 255.0).clamp(0.0, 1.0)
      g_val = (255 - 155 * ratio).to_u8
      tex.color_mod = SDL::Color[255_u8, g_val, g_val]
      gamedata.renderer.copy(tex, nil, dst)
      tex.color_mod = SDL::Color[255_u8, 255_u8, 255_u8]
    else
      gamedata.renderer.copy(tex, nil, dst)
    end
  end
end

class SimpleWalker < TDEnemy
  class_field(display_name, String, "unit_simple_walker")
  class_field(max_health, Int32, 150)
  class_field(width, Float64, 0.9)
  class_field(height, Float64, 0.9)
  class_field(speed, Float64, 1.5)


  # явный инициализатор — чтобы дефолты class_field подкласса
  # гарантированно применились (та же схема, что у зданий)
  def initialize(gamedata, x, y)
    super
  end
end

im a noob in crystal came from python for game dev i also used C#

WHAT I WANT TO DO: make an instance(?) of SimpleWalker class

THE ERROR IS:

Showing last frame. Use --error-trace for full trace.

Error: instance variable '@path_recalc_interval’ of TDEnemy must be Float64, not Nil

Instance variable ‘@path_recalc_interval’ was used before it was initialized in one of the ‘initialize’ methods, rendering it nilable

BUT i initialized it already:

property path_recalc_interval : Float64 = 2.0
property path_recalc_timer : Float64

as 2.0

and here in TDEnemy.initialize i get this error here:

@path_recalc_timer = rand(@path_recalc_interval.not_nil!)

but what it is, i already declared it in class as 2.0. how can i fix this?

btw class_field is a macro like this but it is not related to the error:

macro class_field(name, type, value)
class_property {{name}} : {{type}} = {{value}}

property {{name}} : {{type}} = {{value}}
end

sorry for bad english

say if need more info

I can’t run it because this code depends on a lot of other code that is not here (SDL, Gamedata, etc), so I can’t reproduce it but it looks like you’re running into an issue where you’re referencing self before the object is fully initialized. That usually raises this compile-time error.

Sometimes this means explicit self or referencing an instance variable. Both of those happen in your initialize.

Also, what version of Crystal are you using? I want to say some of this got more friendly in 1.20 or 1.21.

okay thank you!

BTW I ALSO CANT REPRODUCE IT!

Minimal version looks like this:

class TDEnemy

  property path_recalc_interval : Float64 = 2.0

  property path_recalc_timer : Float64


  def initialize(x : Int32, y : Int32)

    @path_recalc_timer = rand(@path_recalc_interval)

  end

end


class SimpleWalker < TDEnemy

  def initialize(x, y)

    super

  end

end

SimpleWalker.new(5, 5)

and it compiles

okay i will search the error in some other place of the project and tell you if i find something. and yes i have the last version of crystal :)

hello again! okay i finally reproduced the error (i also got this error not only in enemies code but in buildings code) :

# repro.cr
macro class_field(name, type, value)
  class_property {{name}} : {{type}} = {{value}}
  property {{name}} : {{type}} = {{value}}
end

class Gamedata
  property registry

  def initialize
    @registry = {
      "CopperWall"  => CopperWall,
      "CopperTurret" => CopperTurret,
    } of String => (CopperWall.class | CopperTurret.class)
  end

  def place_building_by_name(name : String, x : Int32, y : Int32, angle : Int32 = 0)
    klass = registry[name]
    klass.new(self, x, y, angle)          
  end
end

class Building
  class_field(display_name, String, "Неизвестное")
  class_field(width, Int32, 1)
  class_field(height, Int32, 1)
  property x : Int32 = -1                
  property y : Int32 = -1                 
  class_field(max_health, Int32, 100)
  property health : Int32
  property angle : Int32 = 0             
  property gamedata : Gamedata

  def initialize(@gamedata, @x : Int32, @y : Int32, @angle : Int32 = 0)
    @health = @max_health.not_nil!       
    register_in_grid
  end

  def register_in_grid
    @width + @height + @x + @y
  end
end

class Wall < Building
  class_field(max_health, Int32, 300)

  def initialize(@gamedata, @x : Int32, @y : Int32, @angle : Int32 = 0)
    super(@gamedata, @x, @y, @angle)
  end
end

class CopperWall < Wall
  class_field(display_name, String, "CopperWall")
  class_field(max_health, Int32, 1000)
  property image : String?

  def initialize(gamedata : Gamedata, @x : Int32, @y : Int32, @angle : Int32 = 0)
    super(gamedata, x, y, angle)          # геттеры в аргументах super
    @image = display_name                 # геттер после super
  end
end

class Turret < Building
  class_field(max_health, Int32, 600)

  def initialize(@gamedata, @x, @y, @angle)   # параметры БЕЗ типов
    super(@gamedata, @x, @y, @angle)          
  end
end

class CopperTurret < Turret
  class_field(max_health, Int32, 600)

  def initialize(@gamedata, @x, @y, @angle)
    super(@gamedata, @x, @y, @angle)
  end
end

Gamedata.new.place_building_by_name("CopperWall", 10, 10, 0) 

you can run this in one .cr file, no external files needed. you will get the same error

the error is still this:

Showing last frame. Use --error-trace for full trace.

Error: instance variable ‘@max_health’ of Building must be Int32, not Nil

**Instance variable ‘@max_health’ was used before it was initialized in one of the ‘initialize’ methods, rendering it nilable
**
i mean what? i initialized it everywhere as class_field(max_health, Int32, some_number)

help and explain what is wrong plz

even simpler version:


class A

  property max_health : Int32 = 100

  property health : Int32




  def initialize

    @health = @max_health.not_nil!

  end

end




class B < A

  property max_health : Int32 = 200




  def initialize

    super

  end

end




class C < A

  property max_health : Int32 = 300




  def initialize

    super

  end

end




k : B.class | C.class = ARGV.empty? ? B : C

k.new

something related with union with .class types maybe

I don’t know the intricacies of object creation and why the variable is uninitialized at that moment, but if you use the property (max_health instead of @max_health) it seems to work.

Looks

Looks like simplified version works correctly if you add super to A#initialize

yes but why? why it works with getter only?

okay anyway, if it works it works, thank you, but it is probably a compiler bug, thank you for the solution :)

btw if a compiler dev reading this please answver if this is a compiler bug or not

yes but what kind of magic is this XD and it doesnt work with the first example

I’d prob just do something like this instead:

abstract class A
  property health : Int32
  property max_health : Int32
 
  def initialize(@max_health : Int32 = 100)
    @health = @max_health
  end
end

class B < A
  def initialize
    super 200
  end
end

class C < A
  def initialize
    super 300
  end
end

k : B.class | C.class = ARGV.empty? ? B : C
 
pp k.new # => #<B:0x7ff6fe18dfd0 @health=200, @max_health=200>

Versus defining the same ivar in every child class.

thanks but i will just use:

def summon_enemy_by_name(name : String, x : Int32, y : Int32) : TDEnemy?

  case name

  when "SimpleWalker" then SimpleWalker.new(self, x, y)

  else

    puts "❌ Неизвестный враг: #{name}"

    nil

  end

end
def place_building_by_name(name : String, x : Int32, y : Int32, angle : Int32 = 0)

  return nil unless 0 <= x < ширина_сетки && 0 <= y < высота_сетки




  building = case name

             when "CopperWall"  then CopperWall.new(self, x, y, angle)

             when "HealingWall"  then HealingWall.new(self, x, y, angle)

             when "CopperTurret" then CopperTurret.new(self, x, y, angle)

             else raise "неизвестное здание: #{name}"

             end




  buildings << building

  building

end

case instead of union metaclasses

but can you plz explain why is it not initialized here i just want to understand it why it works like this with union .class types

Edit: sorry guys (and @carlos) . Tried to help. Didn’t work this time. :slightly_smiling_face:

Did anybody understand any of that? :exploding_head:

Interestingly, using the getter instead of the ivar avoids the semantic error:

class A
  property max_health : Int32 = 100
  property health : Int32

  def initialize
    @health = max_health
  end
end

class B < A
  property max_health : Int32 = 200
end

class C < A
  property max_health : Int32 = 300
end

k : B.class | C.class = ARGV.empty? ? B : C
p k.new

I have no answer for the “why”. This is likely a semantic error related to B | C becoming A+.

In any case, I can’t encourage you to follow @Blacksmoke16 solution, with a kwarg it becomes even better:

def initialize
  super(max_health: 300)
end