Eaglev0.1.0

Interactions

Every input pattern with a live event log: clicks, drags, wheel zoom, text, gamepads, signals, timers.

Try everything; the log on the right narrates

Source: examples/interactions/main.cr

examples/interactions/main.cr

require "../../src/eagle"
include Eagle

# Every major interaction pattern in one scene, with a live event log.
# Run with EAGLE_DEMO=1 to have the engine drive the interactions itself.
#
#  1. Keyboard actions (WASD/arrows/gamepad) move the player; modifiers change speed.
#  2. Mouse hover / click / double-click / right-click on shapes (per-node input).
#  3. Drag & drop of crates (press, move, release) with snapping.
#  4. Mouse wheel zooms the camera; middle-drag pans it.
#  5. UI: buttons, slider, checkbox, text input, Tab focus navigation.
#  6. Signals between nodes (door opens when the switch is pressed).
#  7. Area2D triggers (walk into the zone).
#  8. Timers, tweens & audio feedback on interaction.
#  9. Pause (P) and scene reset (R); window resize; file drop.
# 10. Gamepad buttons/axes (real or injected).

class EventLog < CanvasLayer
  @lines = [] of String
  @label = Label.new("")
  MAX = 12
  @@instance : EventLog? = nil

  def self.instance : EventLog; @@instance ||= new; end
  def self.log(msg : String); instance.log(msg); end

  def initialize
    super("Log", 10)
    @label.position = v2(Window.width - 460, 40)
    @label.color = Color.gray(0.85)
    add(@label)
  end

  def lines; @lines; end

  def log(msg : String)
    @lines << "#{Clock.elapsed.round(1)}s #{msg}"
    @lines.shift if @lines.size > MAX
    @label.text = @lines.join("\n")
  end

  def resized(w : Int32, h : Int32)
    @label.position = v2(w - 460, 40)
  end
end

class Player < Sprite2D
  property speed = 200_f32
  signal entered_zone
  signal left_zone

  def initialize
    super(Texture.new(Image.circle(28, Color.hex("#ffd166"))), v2(160, 300), "Player")
    add(Polygon2D.rect(6, 6, Color::BLACK).tap(&.position = v2(6, -4)))
  end

  def process(dt : Float32)
    v = Input.vector("left", "right", "up", "down")
    mult = Input.shift? ? 2.5_f32 : 1_f32
    self.position += v * @speed * mult * dt
    self.position = position.clamp(v2(20, 60), Window.size - v2(20, 20))
  end
end

# A shape that reacts to hover, click, double-click and right-click.
class Clickable < Polygon2D
  property? hovered = false
  @flash = 0_f32
  @label : String

  def initialize(@label, position : Vec2, color : Color)
    super(Polygon2D.circle(26, color).points, color, name: @label, position: position)
  end

  def inside?(p : Vec2) : Bool; to_local(p).length <= 26; end

  def input(e : Event)
    case e
    when MouseMotionEvent
      was = @hovered
      @hovered = inside?(e.position)
      EventLog.log("hover #{@label}") if @hovered && !was
    when MouseButtonEvent
      return unless e.pressed? && inside?(e.position)
      e.handled = true # stop the click reaching nodes behind
      @flash = 0.25_f32
      Sounds.click(e.clicks)
      if e.button.right?
        EventLog.log("right-click #{@label}")
        self.color = Color.hsv(rand(360), 0.7, 1)
      elsif e.clicks >= 2
        EventLog.log("double-click #{@label}")
        Tween.value(self.scale, v2(1.5, 1.5), 0.15, ease: :back_out) { |s| self.scale = s }.on_complete do
          Tween.value(self.scale, Vec2::ONE, 0.2) { |s| self.scale = s }
        end
      else
        EventLog.log("click #{@label} (#{e.button})")
      end
    end
  end

  def process(dt : Float32)
    @flash = Math.max(0_f32, @flash - dt)
  end

  def draw(g : Graphics)
    g.circle(0, 0, 30, DrawMode::Line, Color::WHITE) if @hovered
    g.circle(0, 0, 26 + @flash * 40, DrawMode::Line, Color::WHITE.with_alpha(@flash * 3)) if @flash > 0
    super
    g.print(@label, 0, -6, Color::BLACK, align: TextAlign::Center)
  end
end

# Drag & drop with snapping to a 40px grid.
class Crate < Polygon2D
  @dragging = false
  @offset = Vec2::ZERO
  @@dragging_any = false

  def initialize(position : Vec2, color : Color)
    super(Polygon2D.rect(48, 48, color).points, color, name: "Crate", position: position)
  end

  def inside?(p : Vec2) : Bool
    l = to_local(p)
    l.x.abs <= 24 && l.y.abs <= 24
  end

  def input(e : Event)
    case e
    when MouseButtonEvent
      if e.pressed? && e.button.left? && inside?(e.position) && !@@dragging_any
        @dragging = true; @@dragging_any = true
        @offset = position - e.position
        self.z_index = 1
        e.handled = true
        EventLog.log("drag start crate")
      elsif e.released? && @dragging
        @dragging = false; @@dragging_any = false
        self.z_index = 0
        snapped = v2(Mathf.snapped(position.x, 40), Mathf.snapped(position.y, 40))
        Tween.value(position, snapped, 0.12, ease: :quad_out) { |p| self.position = p }
        Sounds.drop
        EventLog.log("drop crate at #{snapped.round}")
      end
    when MouseMotionEvent
      self.position = e.position + @offset if @dragging
    end
  end

  def draw(g : Graphics)
    g.rect(-26, -26, 52, 52, DrawMode::Line, Color::WHITE) if @dragging
    super
  end
end

class Switch < Area2D
  signal toggled(on : Bool)
  property? on = false

  def initialize(position : Vec2)
    super("Switch", position)
    box(40, 20)
    add(Polygon2D.rect(40, 20, Color.hex("#adb5bd")))
    on_body_entered do |b|
      next unless b.is_a?(Player)
      @on = !@on
      emit_toggled(@on)
      EventLog.log("switch #{@on ? "on" : "off"} (Area2D signal)")
    end
  end

  def draw(g : Graphics)
    g.rect(-14, -6, 28, 12, color: @on ? Color::GREEN : Color::RED)
  end
end

class Door < StaticBody2D
  @open = false
  @height = 100_f32

  def initialize(position : Vec2)
    super("Door", position)
    box(20, 100)
  end

  def open=(v : Bool)
    @open = v
    Tween.value(@height, v ? 4_f32 : 100_f32, 0.4, ease: :cubic_in_out) { |h| @height = h }
    body.enabled = !v
  end

  def draw(g : Graphics)
    g.rect(-10, -50, 20, @height, color: Color.hex("#8d99ae"))
  end
end

module Sounds
  @@click : Sound? = nil
  @@drop : Sound? = nil
  def self.init
    @@click = Sound.tone(900, 0.04, Sound::Wave::Square, 0.2)
    @@drop = Sound.tone(300, 0.1, Sound::Wave::Triangle, 0.3)
  end
  def self.click(n); @@click.try(&.play(pitch: n >= 2 ? 1.5 : 1)); end
  def self.drop; @@drop.try(&.play); end
end

class InteractionsDemo < App
  @player = Player.new
  @cam = Camera2D.new(zoom: 1)
  @world = Node2D.new("World")
  @paused = false
  @status = Label.new("")
  @zone_inside = false
  @gamepad_label = Label.new("gamepad: none")

  def load
    Sounds.init
    Input.map "left", Key::A, Key::Left, Input.axis(GamepadAxis::LeftX, -1)
    Input.map "right", Key::D, Key::Right, Input.axis(GamepadAxis::LeftX, 1)
    Input.map "up", Key::W, Key::Up, Input.axis(GamepadAxis::LeftY, -1)
    Input.map "down", Key::S, Key::Down, Input.axis(GamepadAxis::LeftY, 1)
    Input.map "action", Key::Space, GamepadButton::A
    root = SceneTree.root
    root.add(@world, @cam, EventLog.instance)
    build_world
    build_ui
    @cam.position = Window.center
    @cam.limits = Rect.new(-400, -300, Window.width + 800, Window.height + 600)
    demo_script if ENV["EAGLE_DEMO"]? == "1"
  end

  def build_world
    @world.clear_children
    # 2 & 3: clickable shapes and draggable crates
    @world.add(Clickable.new("A", v2(420, 140), Color.hex("#4cc9f0")), Clickable.new("B", v2(500, 140), Color.hex("#f72585")))
    @world.add(Crate.new(v2(420, 260), Color.hex("#b5651d")), Crate.new(v2(500, 260), Color.hex("#c98a3c")))
    # 6 & 7: switch -> door via signal; trigger zone
    switch = Switch.new(v2(200, 440))
    door = Door.new(v2(330, 420))
    switch.on_toggled { |on| door.open = on }
    zone = Area2D.new("Zone", v2(120, 180)).box(120, 100)
    zone.on_body_entered { |b| if b.is_a?(Player); @zone_inside = true; EventLog.log("entered zone"); @player.emit_entered_zone; end }
    zone.on_body_exited { |b| if b.is_a?(Player); @zone_inside = false; EventLog.log("left zone"); end }
    body = KinematicBody2D.new("PlayerBody").box(28, 28)
    body.add(@player.tap(&.position = Vec2::ZERO))
    body.position = v2(160, 300)
    @world.add(zone, switch, door, body)
    @world.add(Timer.new(3.0) { EventLog.log("timer tick (every 3s)") }.start)
  end

  def build_ui
    ui = CanvasLayer.new("UI", 5)
    panel = Panel.new(size: v2(300, 0))
    panel.position = v2(Window.width - 360, Window.height - 340)
    panel.fit_content = true
    box = VBox.new(size: v2(280, 0))
    box.position = v2(10, 10)
    box.fit_content = true
    box.add(Label.new("UI interactions (Tab cycles focus)"))
    row = HBox.new
    row.add(Button.new("Beep") { Sounds.click(1); EventLog.log("button Beep") })
    row.add(Button.new("Shake") { @cam.shake(6, 0.3); EventLog.log("button Shake") })
    row.add(Button.new("Reset (R)") { build_world; EventLog.log("scene reset") })
    box.add(row)
    slider = Slider.new(50, 400, 200, step: 10)
    slider.on_value_changed { |v| @player.speed = v; EventLog.log("speed #{v.to_i}") }
    box.add(HBox.new.tap { |h| h.add(Label.new("Speed"), slider.tap(&.size_flags = SizeFlags::ExpandX)) })
    check = CheckBox.new("Nearest filtering", false)
    check.on_toggled { |on| Texture.default_filter = on ? GPU::Filter::Nearest : GPU::Filter::Linear; EventLog.log("checkbox #{on}") }
    box.add(check)
    input = TextInput.new("", "type a message, Enter to send")
    input.on_submitted { |t| EventLog.log("submitted: #{t}"); input.text = "" }
    box.add(input)
    box.add(@gamepad_label)
    panel.add(box)
    @status.position = v2(10, 10)
    ui.add(panel, @status)
    SceneTree.root.add(ui)
  end

  def input(e : Event)
    case e
    when KeyEvent
      if e.pressed? && !e.repeat?
        case e.key
        when Key::P
          @paused = !@paused
          SceneTree.paused = @paused
          EventLog.log(@paused ? "paused (P)" : "resumed")
        when Key::R then build_world; EventLog.log("scene reset (R)")
        when Key::Escape then Eagle.quit unless Control.focused
        end
      end
    when MouseWheelEvent
      @cam.zoom = (@cam.zoom * (1 + e.delta.y * 0.1)).clamp(0.5_f32, 3_f32)
      EventLog.log("wheel zoom #{@cam.zoom.round(2)}")
    when MouseMotionEvent
      @cam.position -= e.delta / @cam.zoom if Input.mouse_down?(MouseButton::Middle)
    when WindowEvent
      EventLog.log("window #{e.kind} #{e.size.round}") if e.kind.resized?
    when FileDropEvent then EventLog.log("file dropped: #{File.basename(e.path)}")
    when GamepadConnectionEvent then EventLog.log("gamepad #{e.connected? ? "connected" : "removed"} ##{e.gamepad}")
    when GamepadButtonEvent then EventLog.log("gamepad button #{e.button} #{e.pressed? ? "down" : "up"}") if e.pressed?
    end
  end

  def update(dt : Float32)
    if Input.pressed?("action")
      EventLog.log("action pressed (space / A)")
      @cam.shake(3, 0.15)
    end
    if g = Input.gamepad
      @gamepad_label.text = "gamepad: #{g.name} L#{g.left_stick.round} A:#{g.down?(GamepadButton::A)}"
    end
    @status.text = "WASD move (Shift = run)  wheel zoom  middle-drag pan  P pause  R reset  Tab focus  Esc quit   zone: #{@zone_inside ? "inside" : "outside"}   #{@paused ? "PAUSED" : ""}   fps #{Clock.fps.round}"
  end

  def unload
    puts EventLog.instance.lines.join("\n") if ENV["EAGLE_DEMO"]? == "1"
  end

  # Scripted walkthrough used for the automated screenshot.
  def demo_script
    Script.hold(Key::D, 0.6, start: 0.2)
    Script.at(0.9) { Script.click(v2(420, 140)) }
    Script.at(1.1) { Script.click(v2(500, 140), clicks: 2) }
    Script.at(1.3) { Script.click(v2(420, 140), MouseButton::Right) }
    Script.drag(v2(500, 260), v2(600, 330), seconds: 0.4, start: 1.5)
    Script.at(2.1) { Script.wheel(v2(0, 2), v2(400, 300)) }
    Script.at(2.3) { Script.click(SceneTree.root.find_all(Button).find! { |b| b.text == "Beep" }.global_rect.center) }
    Script.at(2.5) { Script.key(Key::Tab); Script.key(Key::Tab) } # Tab to the next widgets
    Script.at(2.6) { Script.click(SceneTree.root.find_all(TextInput).first.global_rect.center) }
    Script.at(2.7) { Script.type("hello from a script"); Script.key(Key::Enter) }
    Script.at(2.9) { Script.connect_gamepad(0); Script.gamepad_button(0, GamepadButton::A) }
    Script.at(3.1) { Script.key(Key::Escape) }
    Script.hold(Key::S, 0.6, start: 3.2)
    Script.hold(Key::A, 0.4, start: 3.9)
    Script.at(4.4) { Script.drop_file("/tmp/level.png") }
  end
end

Eagle.run(InteractionsDemo, title: "Eagle interactions", width: 1100, height: 700)