Skip to content

Instantly share code, notes, and snippets.

@apples
Created November 4, 2018 21:07
Show Gist options
  • Select an option

  • Save apples/4f301e3cae3ac761832ef15066e99ea4 to your computer and use it in GitHub Desktop.

Select an option

Save apples/4f301e3cae3ac761832ef15066e99ea4 to your computer and use it in GitHub Desktop.

Backend renderer interface:

class render_context {
public:
    virtual ~render_context() = 0;
    virtual void begin() = 0;
    virtual void end() = 0;
    virtual void draw_rectangle(const std::string& texture, glm::vec2 position, glm::vec2 size) = 0;
    virtual void draw_text(const std::string& text, const std::string& font, const glm::vec4& color, glm::vec2 position, float size) = 0;
    virtual float get_text_width(const std::string& text, const std::string& font) = 0;
};

inline render_context::~render_context() = default;

The render_context implementation can be anything, e.g. an OpenGL renderer, a debug printer that just prints commands, a mock renderer for unit testing, etc.

DOM node layout:

struct block_layout {
    glm::vec2 position = {0, 0};
    glm::vec2 size = {0, 0};
    bool visible = true;
};

The visible field is somewhat optional, but sometimes an element might need to exist without being rendered, and it's easy to implement.

DOM node base class (here named widget for legacy reasons):

class widget {
public:
    widget() = default;
    virtual ~widget() = default;
    widget(const widget&) = delete;
    widget(widget&&) = delete;
    widget& operator=(const widget&) = delete;
    widget& operator=(widget&&) = delete;

    widget(render_context& renderer);

    widget* get_parent() const;
    widget* get_next_sibling() const;
    render_context* get_renderer() const;

    virtual void draw_self() const;
    virtual std::string get_type() const;

    std::shared_ptr<widget> create_widget(const std::string& type) const;

    const std::vector<std::shared_ptr<widget>>& get_children() const;
    void add_child(std::shared_ptr<widget> child);
    void remove_child(widget* child);
    void replace_child(widget* child, std::shared_ptr<widget> new_child);
    void clear_children();

    void set_attribute(const std::string& name, sol::optional<std::string> value);
    sol::optional<std::string> get_attribute(const std::string& name) const;
    const std::unordered_map<std::string, std::string>& get_all_attributes() const;

    std::function<bool(widget& self, glm::vec2 click_pos)> on_click;

    virtual void calculate_layout();
    const block_layout& get_layout() const;

protected:
    void set_layout(const block_layout& lo);

private:
    std::vector<std::shared_ptr<widget>> children = {};
    std::unordered_map<std::string, std::string> attributes = {};
    widget* parent = nullptr;
    widget* next_sibling = nullptr;
    render_context* renderer = nullptr;
    block_layout layout;
};

std::vector<widget*> get_descendent_stack(const widget& widget, const glm::vec2& position);
void calculate_all_layouts(widget& root);
void draw_all(widget& root);

The sol::optional type is equivalent to std::optional, but this code uses Sol 2 for Lua bindings, which requires some extra features not present in std::optional.

The on_click event is provided as an example, in real code there would probably be more events.

The exact implementation of the event callbacks and attribute map isn't really interesting, all that matters is that there's a way to set them.

The next_sibling pointer isn't necessary, but it allows you to traverse the tree without recursion or a stack.

Each node stores a pointer to the render_context, which will be needed in any overridden draw_self() methods.

This base class is not abstract, it can be instantiated and behaves kinda like a <div> in HTML. It draws nothing and has a box layout.

Derived classes just need to implement the get_type() method, draw_self() and calculate_layout() are both optional.

The create_widget() method should be implemented such that it takes a name, and returns an instance of the widget-derived class that returns that same name when get_type() is called. E.g. create_widget("widget") should return an instance of this widget class.

Lua React clone structure and public interface (a beta implementation can be found in vdom.lua):

local class = require('class')

local function create_element(element_type, config, ...)
    -- Creates a vdom element based on the input.
    -- The ... are the children, which should be vdom elements themselves.
    -- Children can also be arrays which are flattened, or nil which is ignored.
    return {
        type,
        props,
        children
    }
end

local function update_widget_properties(widget, prev_props, next_props)
    -- Calls widget:set_attribute() for each prop.
end

local function instantiate(element, parent_widget)
    -- Recursively creates DOM nodes for each element.
    return {
        widget,
        element,
        child_instances
        component_instance
    }
end

local function reconcile(parent_widget, instance, element)
    -- Recursivelty synchronizes the vdom instances with the actual DOM.
    -- DOM nodes will be created, removed, replaced, or updated here.
    -- instantiate() will be called as necessary.
    -- Component render() methods are also called here.
    return {
        widget,
        child_instance,
        element
    }
end

local function render(element, container)
    -- The public entry point for setting up the vdom.
    -- This is the actual implementation.
    return reconcile(container, nil, element)
end

-- Base class for all components.
-- Public data members are `props` and `state`.
local component_base = class()

function component_base:constructor(props)
    -- Derived classes can override this and call it with `self:super(props)`
    self.props = props
    self.state = self.state or {}
end

function component_base:set_state(partial_state)
    -- Merges partial_state with the current state.
    -- Triggers a reconciliation of this branch of the vdom.
    -- State should not be updated except through this method.
end

local function component()
    -- Creates a derived class. Lua is nice.
    return class(component_base)
end

-- Public exports.
return {
    component = component,
    create_element = create_element,
    render = render
}

The class module is my own, it implements idiomatic Lua classes with some extra functionality to handle constructor() methods.

And in case you're not familiar with React, here's a sample component:

local vdom = require('vdom')

local inventory_menu = vdom.component()

function inventory_menu:constructor(props)
    assert(props)
    assert(props.inventory)
    assert(props.currency)
    assert(props.on_close)
    
    self:super(props)
end

function inventory_menu:on_click(widget, pos)
    self.props.on_close()
end

function inventory_menu:render()
    local on_click = bind(self.on_click, self)

    local item_labels = {}
    
    for i,v in ipairs(self.props.inventory) do
        item_labels[#item_labels + 1] = vdom.create_element(
            'label',
            {
                left = 32,
                top = -65 - (i * 24),
                height = 24,
                text = v.display_name .. ' (' .. tostring(v.amount) .. ')',
                color = v.selected and '#ff0' or '#fff',
            }
        )
    end

    return vdom.create_element(
        'panel',
        {
            width = '36%h',
            height = '100%',
            texture = 'inventory_menu',
        },
        vdom.create_element('panel', { texture = 'red_x', width = 32, height = 32, right = -1, top = -1, on_click = on_click }),
        vdom.create_element(currency, { amount = self.props.currency }),
        item_labels
    )
end

This component takes in a list of inventory items, an amount of currency, and an on_close callback.

It builds a list of label nodes for each inventory item, and shows them in a panel, along with the currency and a "red X" close button.

And an example component that uses it:

local menu_icon = vdom.component()

function menu_icon:constructor(props)
    self:super(props)
    self.state = {
        show_menu = false,
    }
end

function menu_icon:on_icon_click(widget, pos)
    self:set_state({
        show_menu = true,
    })
end

function menu_icon:on_menu_close()
    self:set_state({
        show_menu = false,
    })
end

function menu_icon:render()
    local on_icon_click = bind(self.on_icon_click, self)
    local on_menu_close = bind(self.on_menu_close, self)

    return vdom.create_element(
        'widget',
        { width='100%', height='100%' },
        vdom.create_element('panel', { width=64, height=64, texture='hamburger', on_click=on_icon_click }),
        self.state.show_menu and vdom.create_element(
            inventory_menu,
            {
                on_close = on_menu_close,
                currency = self.props.currency,
                inventory = self.props.inventory,
            }
        )
    )
end

Notice how the first parameter of create_element can either be a string which is the name of a widget type, or it can be a component class.

And finally, to render the menu_icon as our root document element:

local vdom_root = vdom.render(vdom.create_element(menu_icon, nil), root_widget)

And that's... about all there is.

To get data from the game state into the vdom state, it's possible to just call vdom_root.component_instance:set_state({...}).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment