Introduction #
The task that is solved here is not real, but it’s still a good example of (probably?) real work with Opal. I could choose some complex enough JavaScript library and write a simple wrapper using Opal, but there’s no fun. Instead, let’s write a wrapper for existing rich client-side application (it may show you how to wrap your existing application logic). Well, wrapper for something like a client-side scheduler may sound boring, so I have chosen a JavaScript-based browser game called BrowserQuest
written
by Mozilla, and I’ll show you how to write a bot for it using Opal.
Opal #
There are so many posts about Opal , so I’m just going to say “it’s a Ruby to JavaScript” compiler, that’s enough.
Environment #
First of all, we need something that runs the game and injects a bot into the page. I, personally, while writing integration tests (this is the place, where we usually face to web drivers), prefer PhantomJS, but it’s headless, so you can’t enjoy watching how your bot works. We have to use something like Capybara + Selenium:
# Gemfile gem 'capybara' gem 'selenium-webdriver' # runner.rb require 'capybara' Capybara.register_driver :selenium do |app| Capybara::Selenium::Driver.new(app, :browser => :firefox) end Capybara.javascript_driver = :selenium Capybara.default_driver = :selenium Capybara.run_server = false
So, the script registers a driver, specifies its browser (Firefox), makes it default and runs Capybara in browser mode (i.e. without own server in the background)
Opening the page #
Dead simple:
Capybara.current_session.visit('http://browserquest.mozilla.org') # or in object-oriented style class Game include Capybara::DSL def initialize(url) # all methods of # Capybara.current_session # are available here visit(url) end def play # logic of the bot end end Game.new('http://browserquest.mozilla.org/').play
Now it runs a Firefox and opens the page with the game.
Compiling Opal #
So, there are two ways to compile Ruby into JavaScript:
- compiling Ruby code as a string to JavaScript string
- compiling specified Ruby file to JavaScript string
To compile a file with Ruby, run:
Opal.append_path('some/path/to/dir/with/your/files') Opal::Builder.build('relative/path/from/that/dir/to/you/file')
To compile a string with ruby:
Opal.compile("plain ruby code")
The first way is what we really need:
- create a directory with all opal files
- add it to Opal’s load path
- (all
require
commands work as in MRI) - create a file called
app.rb
thatrequire
-s other files - embed
app.rb
to the page
Fetching the data from the game #
This
is the place where the main App
class is created. But! It’s defined in anonymous function, so this variable is not available outside the context.
This game uses CommonJS
to load files. This library caches all previously required files and instantly returns cached result on the seconds require
.
We can use it:
- require
app
file. - wrap any of its methods with some logic that stores current app instance globally and then call
super
I have chosen a method called start
:
# opal/bot.rb module Patch def self.apply %x{ var app = require('app'); oldStart = app.prototype.start; app.prototype.start = function(username) { window.currentApplication = this; oldStart.apply(this, arguments); } } end end Patch.apply
Some explanations:
%x{js code}
just passes provided JavaScript into compiled version (i.e. runs it without any translation)- After compiling this file we have access to a variable
currentApplication
that contains an instance ofApp
class
Starting the game #
As you can see, to start the game you need to:
- type your player name
- wait for ‘Play’ button to activate (become red)
- press ‘Play’ button
- wait until all assets will be loaded
- close instructions that the game opens for any new player
After all of these steps the game will be ready, but the point here is that most of the steps are asynchronous. You can’t just type your name and immediately press ‘Play’ button (and you can’t press ‘Play’ without waiting for loading)
This is the place where promises shine. Opal has its own standard library that
- ships with Opal’s code, so it’s already in the page
- has a class called
Promise
that acts pretty much like ajQuery.Deferred()
require 'promise' promise = Promise.new promise.then { puts 'Done' } promise.fail { puts 'Fail' } promise.resolve # => 'Done' (in JavaScript console) # or promise.reject # => 'Fail'
(Promise
is like an object that is a combination of callback
-s and errback
-s, but you don’t invoke callbacks manually, instead you just switch the state of your promise-object and it automatically triggers callbacks
/errbacks
)
Here is a little helper module that saves our time:
module Utils def wait_for(promise = Promise.new, &waiting) result = waiting.call if !!result promise.resolve else after 0.1 do wait_for(promise, &waiting) end end promise end end # and usage class MyClass include Utils def call some_async_method_without_ability_to_pass_callback wait_for do method_called && result_is_success end end end
wait_for
method takes a promise (which is a blank promise object by default) and a block (which will be converted to JavaScript function). It calls the block and resolve
-s the promise if it is returned true. If not, it calls itself again after 100 ms (after
= setTimeout
) with the same promise object
To type player’s name we should run:
# I'm using opal-jquery here # I think it does not require any explanation input = Element.find('#nameinput') input.value = @player_name wait_for do Element.find('.play.button.disabled').length == 0 end.then do # Button is ready, we can click it here end
To click the button, run:
button = Element.find('#createcharacter .play.button div') button.trigger(:click) wait_for do Element.find('#instructions').has_class?('active') end.then do # The game is ready here # And it shows us instructions # We are almost ready to start the game end
To close instructions, run:
Element.find('#instructions').trigger(:click)
To run this command, call
StartGame.new('Bot player').invoke.then do alert("I'm in the game") end
Time to wrap the code of the game #
As an enter point we are going to use global JavaScript variable currentApplication
. It has a property game
(that, unexpectedly, returns instance of Game
class). game
has a player
property (instance of Player
) and entities
property which is an object containing all entities on the map, their types and coordinates. You can easily find their JavaScript implementations in the GitHub repository of the game.
So, our main objects are:
currentApplication
currentApplication.game
currentApplication.game.player
currentApplication.game.entities
First class for wrapping is definitely an App
:
class Application include Native def self.current self.new(`currentApplication`) end def initialize(native) @native = native end alias_native :game, :game, as: Game def to_n @native end end
So, we have a class called Application
that wraps some native JavaScript object and has a ruby-method game
that calls JavaScript-method game
and wraps it using Game
class (see below). As a bonus, we have a class-method current
that returns wrapped currentApplication
.
The next class is a Game
:
class Game include Native def self.current Application.current.game end def initialize(native) @native = native end alias_native :player, :player, as: Player alias_native :say def to_n @native end def entities res = [] native_entities = `currentApplication.game.entities` Native::Hash.new(native_entities).each do |e_id, e| res << Native(e) end EntityCollection.new(res) end end
And again, this class can wrap any JavaScript game object, has methods player
, say
and entities
(EntityCollection
is our next class to implement).
(we can test method say
write now, just put Game.current.say('Hello')
to the block where the game is ready and start chatting with other players)
Entities #
The game provides a global JavaScript object Types
with all mobs/items/armors/weapons information, it allows to identify unknown entity, compare armors and weapons by rank. Basically, it provides everything for writing a bot logic.
To convert it to Ruby, use Types = Native(`Types`)
and use this object in the Ruby world!
Here is my definition of Entity
class:
class Entity include Native def initialize(native) @native = native end def to_n @native end alias_native :kind def player? Types.isPlayer(kind) end # some other methods # like mob? # or heal? def weapon_rank Types.getWeaponRank(kind) end def armor_rank Types.getArmorRank(kind) end end
Well, this class can wrap player/mob/armor/weapon/healing, but this is only a value-object, we still need to implement our collection-object EntityCollection
:
class EntityCollection def initialize(native_entities) @entities = native_entities.map do |native_entity| Entity.new(native_entity.to_n) end end def players entities = @entities.select(&:player?) EntityCollection.new(entities) end # similar methods like # mobs/weapons/armors/healings # are omitted and are just like 'players' method end
Player class #
(quickly and without any explanation):
class Player include Utils include Native def self.current Game.current.player end def initialize(native) @native = native end alias_native :distance_to, :getDistanceToEntity alias_native :moving?, :isMoving alias_native :attacking?, :isAttacking alias_native :hp, :hitPoints alias_native :max_hp, :maxHitPoints def full_hp? hp == max_hp end alias_native :weapon_name, :getWeaponName alias_native :armor_name, :getArmorName end
Writing the code of the bot #
It’s not as difficult once we have all these classes prepared. The algorithm of farming is like:
- Find a closest mob and kill it
- Find a closest weapon (and pick up if it’s enough close)
- Find a closest armor (and pick up if it’s enough close)
- Find a closest healing (and pick up if it’s enough close)
GOTO
1
All of these steps will be our methods, and all of them must be asynchronous.
Just one method is missing here (closest
):
class EntityCollection def by_distance entities = @entities.sort_by do |entity| Player.current.distance_to(entity) end EntityCollection.new(entities) end def first @entities.first end def last @entities.last end def closest by_distance.first end end
Killing a mob #
def kill_mob closest_mob = Game.current.entities.mobs.closest `#{Game.current.to_n}.makePlayerAttack(#{closest_mob.to_n})` # TODO: move this method to the game class # using alias_native :) end
Picking up an abstract item #
def pick_up `#{Game.current.to_n}.makePlayerGoToItem(#{item.to_n});` end
Picking up a weapon #
def get_armor current_weapon_name = Player.current.weapon_name weapons = Game.current.entities.weapons closest_weapon = weapons.better_than(current_weapon_name).closest if closest_weapon.nil? # No weapon, probably next time return end if Player.current.distance_to(closest_weapon) > 100 # Weapon is too far away, next time return end pick_up(closest_weapon) end
Picking up an armor #
Just like a previous snippet, but with armors
instead of weapons
What’s missing? #
All of these steps should return promises, every single method written below should wait for player to stop moving and attacking. To make this we need some common method like:
def wait_until_inactive promise = Promise.new wait_for do !Player.current.moving? && !Player.current.attacking? end.then do # Wait 1 more second to continue after 1 do promise.resolve end end promise end
And put it to the end of each action method.
Wrapping a wrapper #
We need the main method farm
, right?
def farm kill_mob.then do get_weapon.then do get_armor.then do heal.then do farm end end end end end
This, is probably the thing that I have personally learned during writing this article. Even if you think in Ruby, you still have to deal with asynchronous components like callbacks/promises. When you need to make an HTTP request in Ruby, you just get you favorite HTTP adapter (mine is RestClient
), send a request and your interpreter waits for response. In JavaScript you have to process response in some callback, because you can’t just stop your interpreter (you know, it blocks UI).
Conclusion #
As for me, the main thing Opal gives to you is some ability to think in terms of Ruby classes/modules/inheritance system. But it does not let you completely escape from JavaScript ecosystem (no callbacks? - block is a callback). I would say, most of Opal functionality related to Ruby classes can be replaced with, for example, JsClass
library (which is really wonderful). Opal allows you to compile existing Ruby libraries to JavaScript and use them on the client - this is probably the main feature. Some day significant amount of Ruby libraries will be ported to client-side and probably some day we will think in terms of Ruby even on the client.